diff --git a/src/main/java/com/quickpick/ureca/reserve/controller/ReserveController.java b/src/main/java/com/quickpick/ureca/reserve/controller/ReserveController.java deleted file mode 100644 index 7b3818a..0000000 --- a/src/main/java/com/quickpick/ureca/reserve/controller/ReserveController.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.quickpick.ureca.reserve.controller; - -public class ReserveController { -} diff --git a/src/main/java/com/quickpick/ureca/reserve/repository/ReserveRepository.java b/src/main/java/com/quickpick/ureca/reserve/repository/ReserveRepository.java deleted file mode 100644 index d9dfba9..0000000 --- a/src/main/java/com/quickpick/ureca/reserve/repository/ReserveRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.quickpick.ureca.reserve.repository; - -public class ReserveRepository { -} diff --git a/src/main/java/com/quickpick/ureca/reserve/service/ReserveService.java b/src/main/java/com/quickpick/ureca/reserve/service/ReserveService.java deleted file mode 100644 index 28c25c2..0000000 --- a/src/main/java/com/quickpick/ureca/reserve/service/ReserveService.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.quickpick.ureca.reserve.service; - -public class ReserveService { -} diff --git a/src/main/java/com/quickpick/ureca/reserve/status/ReserveStatus.java b/src/main/java/com/quickpick/ureca/reserve/status/ReserveStatus.java deleted file mode 100644 index b378ef0..0000000 --- a/src/main/java/com/quickpick/ureca/reserve/status/ReserveStatus.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.quickpick.ureca.reserve.status; - -public enum ReserveStatus { - SUCCESS, - FAIL -} \ No newline at end of file diff --git a/src/main/java/com/quickpick/ureca/ticket/domain/Ticket.java b/src/main/java/com/quickpick/ureca/ticket/domain/Ticket.java deleted file mode 100644 index 2a0e3e9..0000000 --- a/src/main/java/com/quickpick/ureca/ticket/domain/Ticket.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.quickpick.ureca.ticket.domain; - -import com.quickpick.ureca.common.domain.BaseEntity; -import com.quickpick.ureca.userticket.domain.UserTicket; -import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - -@Entity -@Table(name = "ticket") -@Getter -@NoArgsConstructor -public class Ticket extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "ticket_id") - private Long ticketId; - - @Column(nullable = false) - private String name; - - @Column(nullable = false) - private int quantity; - - @Column(nullable = false) - private LocalDateTime startDate; - - @Column(nullable = false) - private LocalDateTime reserveDate; - - @OneToMany(mappedBy = "ticket", cascade = CascadeType.ALL, orphanRemoval = true) - private List userTickets = new ArrayList<>(); -} diff --git a/src/main/java/com/quickpick/ureca/ticket/repository/TicketRepository.java b/src/main/java/com/quickpick/ureca/ticket/repository/TicketRepository.java deleted file mode 100644 index 34b9f66..0000000 --- a/src/main/java/com/quickpick/ureca/ticket/repository/TicketRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.quickpick.ureca.ticket.repository; - -import com.quickpick.ureca.ticket.domain.Ticket; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface TicketRepository extends JpaRepository { -} diff --git a/src/main/java/com/quickpick/ureca/user/controller/UserController.java b/src/main/java/com/quickpick/ureca/user/controller/UserController.java deleted file mode 100644 index 5ea1b6a..0000000 --- a/src/main/java/com/quickpick/ureca/user/controller/UserController.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.quickpick.ureca.user.controller; - -public class UserController { -} diff --git a/src/main/java/com/quickpick/ureca/user/repository/UserRepository.java b/src/main/java/com/quickpick/ureca/user/repository/UserRepository.java deleted file mode 100644 index 50abb0e..0000000 --- a/src/main/java/com/quickpick/ureca/user/repository/UserRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.quickpick.ureca.user.repository; - -public class UserRepository { -} diff --git a/src/main/java/com/quickpick/ureca/user/service/UserService.java b/src/main/java/com/quickpick/ureca/user/service/UserService.java deleted file mode 100644 index 972e2b1..0000000 --- a/src/main/java/com/quickpick/ureca/user/service/UserService.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.quickpick.ureca.user.service; - -public class UserService { -} diff --git a/src/main/java/com/quickpick/ureca/common/domain/BaseEntity.java b/src/main/java/com/quickpick/ureca/v1/common/domain/BaseEntity.java similarity index 91% rename from src/main/java/com/quickpick/ureca/common/domain/BaseEntity.java rename to src/main/java/com/quickpick/ureca/v1/common/domain/BaseEntity.java index e4ac893..b76a7f6 100644 --- a/src/main/java/com/quickpick/ureca/common/domain/BaseEntity.java +++ b/src/main/java/com/quickpick/ureca/v1/common/domain/BaseEntity.java @@ -1,26 +1,26 @@ -package com.quickpick.ureca.common.domain; - -import jakarta.persistence.Column; -import jakarta.persistence.EntityListeners; -import jakarta.persistence.MappedSuperclass; -import lombok.Getter; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.annotation.LastModifiedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; - -import java.time.LocalDateTime; - -@Getter -@MappedSuperclass -@EntityListeners(AuditingEntityListener.class) -public abstract class BaseEntity { - @CreatedDate - @Column(length = 6, name = "created_at", updatable = false) - private LocalDateTime createdAt; - - @LastModifiedDate - @Column(length = 6, name = "updated_at") - private LocalDateTime updatedAt; - -} - +package com.quickpick.ureca.v1.common.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + @CreatedDate + @Column(length = 6, name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(length = 6, name = "updated_at") + private LocalDateTime updatedAt; + +} + diff --git a/src/main/java/com/quickpick/ureca/v1/common/init/InitController.java b/src/main/java/com/quickpick/ureca/v1/common/init/InitController.java new file mode 100644 index 0000000..e684db8 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/common/init/InitController.java @@ -0,0 +1,25 @@ +package com.quickpick.ureca.v1.common.init; + +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/init") +public class InitController { + + private final InitService initService; + + @PostMapping + public String initializePost( + @RequestParam(defaultValue = "1000") int ticketCount, + @RequestParam(defaultValue = "10000") int userCount, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime reserveDate + ) { + return initService.initialize(ticketCount, userCount, startDate, reserveDate); + } +} diff --git a/src/main/java/com/quickpick/ureca/v1/common/init/InitService.java b/src/main/java/com/quickpick/ureca/v1/common/init/InitService.java new file mode 100644 index 0000000..2a2ab55 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/common/init/InitService.java @@ -0,0 +1,49 @@ +package com.quickpick.ureca.v1.common.init; + +import com.quickpick.ureca.v1.ticket.domain.Ticket; +import com.quickpick.ureca.v1.ticket.repository.TicketRepositoryV1; +import com.quickpick.ureca.v1.user.domain.User; +import com.quickpick.ureca.v1.user.repository.UserRepositoryV1; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + + +@Service +@RequiredArgsConstructor +public class InitService { + + private final TicketRepositoryV1 ticketRepository; + private final UserRepositoryV1 userRepositoryV1; + + public String initialize(int ticketCount, int userCount, LocalDateTime startDate, LocalDateTime reserveDate) { + ticketRepository.deleteAll(); + userRepositoryV1.deleteAll(); + + Ticket ticket = Ticket.builder() + .name("테스트 티켓") + .quantity(ticketCount) + .startDate(startDate != null ? startDate : LocalDateTime.now().plusDays(1)) + .reserveDate(reserveDate != null ? reserveDate : LocalDateTime.now()) + .build(); + ticketRepository.save(ticket); + + List users = new ArrayList<>(); + for (int i = 1; i <= userCount; i++) { + User user = User.builder() + .id("user" + i) + .password("pw" + i) + .name("User" + i) + .age("20") + .gender("M") + .build(); + users.add(user); + } + userRepositoryV1.saveAll(users); + + return "초기화 완료: 티켓 1개(" + ticketCount + "개 수량), 유저 " + userCount + "명 생성"; + } +} diff --git a/src/main/java/com/quickpick/ureca/v1/common/init/InitTrigger.java b/src/main/java/com/quickpick/ureca/v1/common/init/InitTrigger.java new file mode 100644 index 0000000..942a72b --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/common/init/InitTrigger.java @@ -0,0 +1,26 @@ +//package com.quickpick.ureca.common.init; +// +//import lombok.RequiredArgsConstructor; +//import org.springframework.boot.context.event.ApplicationReadyEvent; +//import org.springframework.context.ApplicationListener; +//import org.springframework.context.annotation.Profile; +//import org.springframework.stereotype.Component; +// +//import java.time.LocalDateTime; +// +//@Component +//@Profile("local") // 배포환경에서는 작동 안 하도록 +//@RequiredArgsConstructor +//public class InitTrigger implements ApplicationListener { +// +// private final InitService initService; +// +// @Override +// public void onApplicationEvent(ApplicationReadyEvent event) { +// LocalDateTime reserveDate = LocalDateTime.now(); +// LocalDateTime startDate = reserveDate.plusDays(1); +// // ticketCount, userCount는 필요에 따라 조정 +// initService.initialize(3000, 10000, startDate, reserveDate); +// } +// +//} \ No newline at end of file diff --git a/src/main/java/com/quickpick/ureca/v1/reserve/controller/ReserveControllerV1.java b/src/main/java/com/quickpick/ureca/v1/reserve/controller/ReserveControllerV1.java new file mode 100644 index 0000000..ade37c6 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/reserve/controller/ReserveControllerV1.java @@ -0,0 +1,53 @@ +package com.quickpick.ureca.v1.reserve.controller; + +import com.quickpick.ureca.v1.reserve.service.ReserveServiceV1; +import com.quickpick.ureca.v1.user.repository.UserRepositoryV1; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/test-reserve") +@Slf4j +public class ReserveControllerV1 { + + private final ReserveServiceV1 reserveServiceV1; + private final UserRepositoryV1 userRepositoryV1; + + public ReserveControllerV1(ReserveServiceV1 reserveServiceV1, UserRepositoryV1 userRepositoryV1) { + this.reserveServiceV1 = reserveServiceV1; + this.userRepositoryV1 = userRepositoryV1; + } + + @PostMapping("/reserve") + public ResponseEntity reserve(@RequestParam Long userId, @RequestParam Long ticketId) { + try { + reserveServiceV1.reserveTicket(userId, ticketId); + return ResponseEntity.ok("예약 성공"); + } catch (Exception e) { + log.error("예약 실패: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("예약 실패: " + e.getMessage()); + } + } + + // open-in-view + Fetch-Join + DTO +// @PostMapping +// public ResponseEntity reserve(@RequestParam Long userId, @RequestParam Long ticketId) { +// try { +// Ticket ticket = reserveServiceV1.reserveTicket(userId, ticketId); +// User user = userRepository.findById(userId).orElseThrow(); +// return ResponseEntity.ok(TicketReserveResponse.of(ticket, user)); +// } catch (Exception e) { +// log.error("예약 실패: {}", e.getMessage()); +// return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null); +// } +// } + + @PostMapping("/cancel") + public ResponseEntity cancelReservation(@RequestParam Long userId, @RequestParam Long ticketId) { + log.info("{}의 티켓 취소 요청", userId); + reserveServiceV1.cancelReservation(userId, ticketId); + return ResponseEntity.ok("예약이 성공적으로 취소되었습니다."); + } +} diff --git a/src/main/java/com/quickpick/ureca/reserve/domain/Reserve.java b/src/main/java/com/quickpick/ureca/v1/reserve/domain/ReserveV1.java similarity index 59% rename from src/main/java/com/quickpick/ureca/reserve/domain/Reserve.java rename to src/main/java/com/quickpick/ureca/v1/reserve/domain/ReserveV1.java index 870da94..1aa8034 100644 --- a/src/main/java/com/quickpick/ureca/reserve/domain/Reserve.java +++ b/src/main/java/com/quickpick/ureca/v1/reserve/domain/ReserveV1.java @@ -1,8 +1,8 @@ -package com.quickpick.ureca.reserve.domain; +package com.quickpick.ureca.v1.reserve.domain; -import com.quickpick.ureca.common.domain.BaseEntity; -import com.quickpick.ureca.reserve.status.ReserveStatus; -import com.quickpick.ureca.user.domain.User; +import com.quickpick.ureca.v1.common.domain.BaseEntity; +import com.quickpick.ureca.v1.reserve.status.ReserveStatusV1; +import com.quickpick.ureca.v1.user.domain.User; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; @@ -11,7 +11,7 @@ @Entity @Getter @NoArgsConstructor -public class Reserve extends BaseEntity { +public class ReserveV1 extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -24,5 +24,5 @@ public class Reserve extends BaseEntity { @Enumerated(EnumType.STRING) @Column(nullable = false) - private ReserveStatus status; + private ReserveStatusV1 status; } diff --git a/src/main/java/com/quickpick/ureca/v1/reserve/dto/TicketReserveResponseV1.java b/src/main/java/com/quickpick/ureca/v1/reserve/dto/TicketReserveResponseV1.java new file mode 100644 index 0000000..3df44e1 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/reserve/dto/TicketReserveResponseV1.java @@ -0,0 +1,20 @@ +package com.quickpick.ureca.v1.reserve.dto; + +import com.quickpick.ureca.v1.ticket.domain.Ticket; +import com.quickpick.ureca.v1.user.domain.User; + +public record TicketReserveResponseV1( + Long ticketId, + String ticketName, + int remainingQuantity, + String reservedByUsername +) { + public static TicketReserveResponseV1 of(Ticket ticket, User user) { + return new TicketReserveResponseV1( + ticket.getTicketId(), + ticket.getName(), + ticket.getQuantity(), + user.getId() + ); + } +} diff --git a/src/main/java/com/quickpick/ureca/v1/reserve/repository/ReserveRepositoryV1.java b/src/main/java/com/quickpick/ureca/v1/reserve/repository/ReserveRepositoryV1.java new file mode 100644 index 0000000..b87dd3d --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/reserve/repository/ReserveRepositoryV1.java @@ -0,0 +1,9 @@ +package com.quickpick.ureca.v1.reserve.repository; + +import com.quickpick.ureca.v1.reserve.domain.ReserveV1; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ReserveRepositoryV1 extends JpaRepository { +} diff --git a/src/main/java/com/quickpick/ureca/v1/reserve/service/ReserveServiceV1.java b/src/main/java/com/quickpick/ureca/v1/reserve/service/ReserveServiceV1.java new file mode 100644 index 0000000..c0498ab --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/reserve/service/ReserveServiceV1.java @@ -0,0 +1,235 @@ +package com.quickpick.ureca.v1.reserve.service; + +import com.quickpick.ureca.v1.ticket.cache.TicketSoldOutCacheV1; +import com.quickpick.ureca.v1.ticket.domain.Ticket; +import com.quickpick.ureca.v1.ticket.repository.TicketRepositoryV1; +import com.quickpick.ureca.v1.user.domain.User; +import com.quickpick.ureca.v1.user.repository.UserRepositoryV1; +import com.quickpick.ureca.v1.userticket.domain.UserTicketV1; +import com.quickpick.ureca.v1.userticket.repository.UserTicketRepositoryV1; +import com.quickpick.ureca.v1.userticket.repository.UserTicketShardingRepositoryV1; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class ReserveServiceV1 { + + @Autowired + private TicketRepositoryV1 ticketRepositoryV1; + + @Autowired + private UserRepositoryV1 userRepositoryV1; + + @Autowired + private UserTicketRepositoryV1 userTicketRepositoryV1; + + @Autowired + private UserTicketShardingRepositoryV1 userTicketShardingRepositoryV1; + + @Autowired + private TicketSoldOutCacheV1 ticketSoldOutCacheV1; + + // 1. +// // 티켓 예약 메서드 (락 X) Average : 586, Throughput : 17.5/sec +// @Transactional +// public void reserveTicket(Long userId, Long ticketId) { +// // 티켓과 사용자 가져오기 +// TicketV1 ticket = ticketRepositoryV1.findById(ticketId).orElseThrow(() -> new RuntimeException("티켓을 찾을 수 없습니다.")); +// UserV1 user = userRepositoryV1.findById(userId).orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다.")); +// +// // 티켓 재고가 없으면 예외 발생 +// if (ticket.getQuantity() <= 0) { +// throw new RuntimeException("매진되었습니다."); +// } +// +// // 티켓 재고 감소 +// System.out.println("감소!"); +// ticket.decreaseCount(); +// ticketRepositoryV1.save(ticket); // 재고 감소 후 저장 +// +// // 예약 정보 저장 +// UserTicketV1 userTicket = new UserTicketV1(user, ticket); +// userTicketRepositoryV1.save(userTicket); +// } + + + // 2. + // 티켓 예약 메서드 (비관적 락) - Pessimistic Lock Average : 6691, Throughput : 419.9/sec +// @Transactional +// public void reserveTicket(Long userId, Long ticketId) { +// +// log.info(">>> reserveTicket called: userId = {}, ticketId = {}", userId, ticketId); +// +// User user = userRepository.findById(userId) +// .orElseThrow(() -> new IllegalArgumentException("User not found")); +// +// System.out.println("Reserve Ticket"); +// Ticket ticket = ticketRepositoryV1.findByIdForUpdate(ticketId) +// .orElseThrow(() -> new IllegalArgumentException("Ticket not found")); +// +// if (ticket.getQuantity() <= 0) { +// throw new IllegalStateException("Ticket out of stock"); +// } +// +// ticket.setQuantity(ticket.getQuantity() - 1); +// userTicketRepository.save(new UserTicket(user, ticket)); +// } + + // 3. + // 티켓 예약 메서드 (비관적 락 + open-in-view) True Average : 14836, Throughput : 263.9/sec + // 티켓 예약 메서드 (비관적 락 + open-in-view) False Average : 13589, Throughput : 275.8/sec + + // 티켓 예약 메서드 (비관적 락 + open-in-view + 네이티브 쿼리) True Average : 15919, Throughput : 244.7/sec + // 티켓 예약 메서드 (비관적 락 + open-in-view + 네이티브 쿼리) False Average : 14961, Throughput : 262.3/sec + + // 티켓 예약 메서드 (open-in-view + FetchJoin + DTO) False Average : 69005, Throughput : 64.9/sec + +// @Transactional +// public Ticket reserveTicket(Long userId, Long ticketId) { +// +// log.info(">>> reserveTicket called: userId = {}, ticketId = {}", userId, ticketId); +// +// // user는 fetch join 하지 않았으므로 별도로 조회 +// User user = userRepository.findById(userId) +// .orElseThrow(() -> new IllegalArgumentException("User not found")); +// +// // fetch join으로 모든 필요한 정보 로딩 +// Ticket ticket = ticketRepositoryV1.findByIdForUpdateNative(ticketId); +// +// if (userTicketRepository.existsByUser_UserIdAndTicket_TicketId(userId, ticketId)) { +// log.warn("이미 예약한 유저입니다. userId={}, ticketId={}", userId, ticketId); +// return ticket; // 중복이면 insert 안 하고 그냥 리턴 +// } +// +// if (ticket.getQuantity() <= 0) { +// throw new IllegalStateException("Ticket out of stock"); +// } +// +// ticket.setQuantity(ticket.getQuantity() - 1); +// userTicketRepository.save(new UserTicket(user, ticket)); +// return ticket; +// } + + + // 4. + // 티켓 예약 메서드 (비관적 락 + 중복방지 + 인덱스 + 네이티브 쿼리) Average : 9734, Throughput : 339.2/sec +// @Transactional +// public void reserveTicket(Long userId, Long ticketId) { +// +// log.info(">>> reserveTicket called: userId = {}, ticketId = {}", userId, ticketId); +// +// User user = userRepository.findById(userId) +// .orElseThrow(() -> new IllegalArgumentException("User not found")); +// +// System.out.println("Reserve Ticket"); +// Ticket ticket = ticketRepositoryV1.findByIdForUpdateNative(ticketId); +// +// if (userTicketRepository.existsUserTicketRaw(userId, ticketId) != null) { +// log.warn("이미 예약한 유저입니다. userId={}, ticketId={}", userId, ticketId); +// return; +// } +// +// // 이 부분이 Redis를 사용하면 더 효율적으로 변경 가능 +// if (ticket.getQuantity() <= 0) { +// throw new IllegalStateException("Ticket out of stock"); +// } +// +// ticket.setQuantity(ticket.getQuantity() - 1); +// userTicketRepository.save(new UserTicket(user, ticket)); +// } + + // 5. + // 티켓 예약 메서드 (비관적 락 + 중복방지 + open-in-view(True) + 인덱스 + Projection + 네이티브 쿼리) Average : 12094, Throughput : 339.1/sec + // 티켓 예약 메서드 (비관적 락 + 중복방지 + open-in-view(False) + Projection + 네이티브 쿼리) Average : 13033, Throughput : 293.7/sec +// @Transactional +// public void reserveTicket(Long userId, Long ticketId) { +// +// log.info(">>> reserveTicket called: userId = {}, ticketId = {}", userId, ticketId); +// +// User user = userRepository.findById(userId) +// .orElseThrow(() -> new IllegalArgumentException("User not found")); +// +// // quantity만 조회하는 Projection으로 변경 +// TicketQuantityProjection ticketProjection = ticketRepositoryV1.findQuantityForUpdate(ticketId); +// if (ticketProjection == null) { +// throw new IllegalArgumentException("Ticket not found"); +// } +// +// if (userTicketRepository.existsUserTicketRaw(userId, ticketId) != null) { +// log.warn("이미 예약한 유저입니다. userId={}, ticketId={}", userId, ticketId); +// return; +// } +// +// if (ticketProjection.getQuantity() <= 0) { +// throw new IllegalStateException("Ticket out of stock"); +// } +// +// // 수량 감소는 직접 쿼리로 처리하거나, 엔티티 조회 후 업데이트 필요 +// ticketRepositoryV1.decreaseQuantity(ticketId); // 이 메서드는 아래에 작성 +// userTicketRepository.save(new UserTicket(user, ticketRepositoryV1.getReferenceById(ticketId))); +// +// } + + + // 티켓 예약 메서드 (비관적 락 + 중복방지 + In-Memory 캐시 + Sharding + 네이티브 쿼리) Average : 13016, Throughput : 474.7/sec + @Transactional + public void reserveTicket(Long userId, Long ticketId) { + log.info("Reserving ticket: userId = {}, ticketId = {}", userId, ticketId); + + if (ticketSoldOutCacheV1.isSoldOut(ticketId)) { + throw new IllegalStateException("이미 매진된 티켓입니다."); + } + + User user = userRepositoryV1.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + if (userTicketShardingRepositoryV1.exists(userId, ticketId)) { + throw new IllegalStateException("이미 예약함"); + } + + Ticket ticket = ticketRepositoryV1.findByIdForUpdate(ticketId) + .orElseThrow(() -> new IllegalArgumentException("Ticket not found")); + + if (ticket.getQuantity() <= 0) { + ticketSoldOutCacheV1.markSoldOut(ticketId); // 캐시 반영 + throw new IllegalStateException("재고 없음"); + } + + ticket.setQuantity(ticket.getQuantity() - 1); + + userTicketShardingRepositoryV1.saveIgnoreDuplicate( + new UserTicketV1(user, ticketRepositoryV1.getReferenceById(ticketId)) + ); + } + + // 예약 취소 메서드 + @Transactional + public void cancelReservation(Long userId, Long ticketId) { + log.info("Cancelling reservation: userId = {}, ticketId = {}", userId, ticketId); + + // 예약 존재 여부 확인 + if (!userTicketShardingRepositoryV1.exists(userId, ticketId)) { + throw new IllegalStateException("예약 내역이 존재하지 않습니다."); + } + + // 예약 삭제 + userTicketShardingRepositoryV1.delete(userId, ticketId); + + // 티켓 수량 복원 (비관적 락으로 안전하게 처리) + Ticket ticket = ticketRepositoryV1.findByIdForUpdate(ticketId) + .orElseThrow(() -> new IllegalArgumentException("Ticket not found")); + + ticket.setQuantity(ticket.getQuantity() + 1); + + // 매진 캐시 초기화 (optional) + if (ticket.getQuantity() > 0) { + ticketSoldOutCacheV1.unmarkSoldOut(ticketId); + } + } + + +} \ No newline at end of file diff --git a/src/main/java/com/quickpick/ureca/v1/reserve/status/ReserveStatusV1.java b/src/main/java/com/quickpick/ureca/v1/reserve/status/ReserveStatusV1.java new file mode 100644 index 0000000..fad588d --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/reserve/status/ReserveStatusV1.java @@ -0,0 +1,6 @@ +package com.quickpick.ureca.v1.reserve.status; + +public enum ReserveStatusV1 { + SUCCESS, + FAIL +} \ No newline at end of file diff --git a/src/main/java/com/quickpick/ureca/v1/ticket/cache/TicketSoldOutCacheV1.java b/src/main/java/com/quickpick/ureca/v1/ticket/cache/TicketSoldOutCacheV1.java new file mode 100644 index 0000000..7ca0a3c --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/ticket/cache/TicketSoldOutCacheV1.java @@ -0,0 +1,22 @@ +package com.quickpick.ureca.v1.ticket.cache; + +import org.springframework.stereotype.Component; + +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class TicketSoldOutCacheV1 { + private final ConcurrentHashMap soldOutMap = new ConcurrentHashMap<>(); + + public boolean isSoldOut(Long ticketId) { + return soldOutMap.getOrDefault(ticketId, false); + } + + public void markSoldOut(Long ticketId) { + soldOutMap.put(ticketId, true); + } + + public void unmarkSoldOut(Long ticketId) { + soldOutMap.remove(ticketId); + } +} diff --git a/src/main/java/com/quickpick/ureca/v1/ticket/controller/TicketControllerV1.java b/src/main/java/com/quickpick/ureca/v1/ticket/controller/TicketControllerV1.java new file mode 100644 index 0000000..6174623 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/ticket/controller/TicketControllerV1.java @@ -0,0 +1,7 @@ +package com.quickpick.ureca.v1.ticket.controller; + +public class TicketControllerV1 { + + + +} diff --git a/src/main/java/com/quickpick/ureca/v1/ticket/domain/Ticket.java b/src/main/java/com/quickpick/ureca/v1/ticket/domain/Ticket.java new file mode 100644 index 0000000..f0b56b4 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/ticket/domain/Ticket.java @@ -0,0 +1,59 @@ +package com.quickpick.ureca.v1.ticket.domain; + +import com.quickpick.ureca.v1.common.domain.BaseEntity; +import com.quickpick.ureca.v1.userticket.domain.UserTicketV1; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "ticket") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ticket extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ticket_id") + private Long ticketId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private int quantity; + + @Column(nullable = false) + private LocalDateTime startDate; + + @Column(nullable = false) + private LocalDateTime reserveDate; + + //@Version + //private Long version; + + @OneToMany(mappedBy = "ticket", cascade = CascadeType.ALL, orphanRemoval = true) + private List userTicketV1s = new ArrayList<>(); + + // 재고 감소 메서드 + public void decreaseCount() { + if (this.quantity > 0) { + this.quantity--; + } else { + throw new RuntimeException("티켓이 매진되었습니다."); + } + } + + // Test용 + public Ticket(String name, int i) { + this.name = name; + this.quantity = i; + } + +} \ No newline at end of file diff --git a/src/main/java/com/quickpick/ureca/v1/ticket/projection/TicketQuantityProjectionV1.java b/src/main/java/com/quickpick/ureca/v1/ticket/projection/TicketQuantityProjectionV1.java new file mode 100644 index 0000000..0c0bce7 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/ticket/projection/TicketQuantityProjectionV1.java @@ -0,0 +1,7 @@ +package com.quickpick.ureca.v1.ticket.projection; + +public interface TicketQuantityProjectionV1 { + Long getTicketId(); + int getQuantity(); + +} diff --git a/src/main/java/com/quickpick/ureca/v1/ticket/repository/TicketRepositoryV1.java b/src/main/java/com/quickpick/ureca/v1/ticket/repository/TicketRepositoryV1.java new file mode 100644 index 0000000..260781a --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/ticket/repository/TicketRepositoryV1.java @@ -0,0 +1,54 @@ +package com.quickpick.ureca.v1.ticket.repository; + +import com.quickpick.ureca.v1.ticket.domain.Ticket; +import com.quickpick.ureca.v1.ticket.projection.TicketQuantityProjectionV1; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Repository +public interface TicketRepositoryV1 extends JpaRepository { + + // 비관적 락 + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select t from Ticket t where t.ticketId = :ticketId""") + Optional findByIdForUpdate(Long ticketId); + + // 비관적 락 (네이티브 쿼리) + @Query(value = "SELECT * FROM ticket WHERE ticket_id = :ticketId FOR UPDATE", nativeQuery = true) + Ticket findByIdForUpdateNative(@Param("ticketId") Long ticketId); + + + // open-in-view + FetchJoin + DTO + 비관적 락 + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select t from Ticket t + left join fetch t.userTicketV1s ut + left join fetch ut.user + where t.ticketId = :ticketId + """) + Optional findByIdForUpdateWithUsers(Long ticketId); + + + // Projection 기반 조회 + @Query(value = "SELECT ticket_id AS ticketId, quantity AS quantity FROM ticket WHERE ticket_id = :ticketId FOR UPDATE", nativeQuery = true) + TicketQuantityProjectionV1 findQuantityForUpdate(@Param("ticketId") Long ticketId); + + @Modifying + @Query(value = "UPDATE ticket SET quantity = quantity - 1 WHERE ticket_id = :ticketId", nativeQuery = true) + void decreaseQuantity(@Param("ticketId") Long ticketId); + + + @Modifying + @Transactional + @Query(value = "UPDATE ticket SET quantity = quantity - 1 WHERE ticket_id = :ticketId AND quantity > 0", nativeQuery = true) + int decreaseQuantityIfAvailable(@Param("ticketId") Long ticketId); +} diff --git a/src/main/java/com/quickpick/ureca/v1/ticket/service/TicketServiceV1.java b/src/main/java/com/quickpick/ureca/v1/ticket/service/TicketServiceV1.java new file mode 100644 index 0000000..c6555ba --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/ticket/service/TicketServiceV1.java @@ -0,0 +1,7 @@ +package com.quickpick.ureca.v1.ticket.service; + +import org.springframework.stereotype.Service; + +@Service +public class TicketServiceV1 { +} diff --git a/src/main/java/com/quickpick/ureca/v1/user/controller/UserControllerV1.java b/src/main/java/com/quickpick/ureca/v1/user/controller/UserControllerV1.java new file mode 100644 index 0000000..18c1049 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/user/controller/UserControllerV1.java @@ -0,0 +1,4 @@ +package com.quickpick.ureca.v1.user.controller; + +public class UserControllerV1 { +} diff --git a/src/main/java/com/quickpick/ureca/user/domain/User.java b/src/main/java/com/quickpick/ureca/v1/user/domain/User.java similarity index 65% rename from src/main/java/com/quickpick/ureca/user/domain/User.java rename to src/main/java/com/quickpick/ureca/v1/user/domain/User.java index 86eaaca..71003dc 100644 --- a/src/main/java/com/quickpick/ureca/user/domain/User.java +++ b/src/main/java/com/quickpick/ureca/v1/user/domain/User.java @@ -1,10 +1,9 @@ -package com.quickpick.ureca.user.domain; +package com.quickpick.ureca.v1.user.domain; -import com.quickpick.ureca.common.domain.BaseEntity; -import com.quickpick.ureca.userticket.domain.UserTicket; +import com.quickpick.ureca.v1.common.domain.BaseEntity; +import com.quickpick.ureca.v1.userticket.domain.UserTicketV1; import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import java.util.ArrayList; import java.util.List; @@ -12,7 +11,10 @@ @Table @Entity @Getter +@Setter +@Builder @NoArgsConstructor +@AllArgsConstructor public class User extends BaseEntity { @Id @@ -36,6 +38,10 @@ public class User extends BaseEntity { private String gender; @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) - private List userTickets = new ArrayList<>(); + private List userTicketV1s = new ArrayList<>(); + + public User(String id) { + this.id = id; + } } diff --git a/src/main/java/com/quickpick/ureca/v1/user/repository/UserRepositoryV1.java b/src/main/java/com/quickpick/ureca/v1/user/repository/UserRepositoryV1.java new file mode 100644 index 0000000..0862f31 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/user/repository/UserRepositoryV1.java @@ -0,0 +1,9 @@ +package com.quickpick.ureca.v1.user.repository; + +import com.quickpick.ureca.v1.user.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserRepositoryV1 extends JpaRepository { +} diff --git a/src/main/java/com/quickpick/ureca/v1/user/service/UserBulkInsertServiceV1.java b/src/main/java/com/quickpick/ureca/v1/user/service/UserBulkInsertServiceV1.java new file mode 100644 index 0000000..f46bf36 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/user/service/UserBulkInsertServiceV1.java @@ -0,0 +1,34 @@ +package com.quickpick.ureca.v1.user.service; + +import com.quickpick.ureca.v1.user.domain.User; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Service +public class UserBulkInsertServiceV1 { + + @PersistenceContext + private EntityManager entityManager; + + @Transactional + public void insertUsersInBulk(int userCount) { + List users = new ArrayList<>(); + for (int i = 0; i < userCount; i++) { + users.add(new User("user" + i)); + } + + int batchSize = 100; + for (int i = 0; i < users.size(); i++) { + entityManager.persist(users.get(i)); + if (i % batchSize == 0) { + entityManager.flush(); + entityManager.clear(); + } + } + } +} diff --git a/src/main/java/com/quickpick/ureca/v1/user/service/UserServiceV1.java b/src/main/java/com/quickpick/ureca/v1/user/service/UserServiceV1.java new file mode 100644 index 0000000..297c6fd --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/user/service/UserServiceV1.java @@ -0,0 +1,4 @@ +package com.quickpick.ureca.v1.user.service; + +public class UserServiceV1 { +} diff --git a/src/main/java/com/quickpick/ureca/userticket/domain/UserTicket.java b/src/main/java/com/quickpick/ureca/v1/userticket/domain/UserTicketV1.java similarity index 55% rename from src/main/java/com/quickpick/ureca/userticket/domain/UserTicket.java rename to src/main/java/com/quickpick/ureca/v1/userticket/domain/UserTicketV1.java index 8825698..1fce7f6 100644 --- a/src/main/java/com/quickpick/ureca/userticket/domain/UserTicket.java +++ b/src/main/java/com/quickpick/ureca/v1/userticket/domain/UserTicketV1.java @@ -1,16 +1,20 @@ -package com.quickpick.ureca.userticket.domain; +package com.quickpick.ureca.v1.userticket.domain; -import com.quickpick.ureca.ticket.domain.Ticket; -import com.quickpick.ureca.user.domain.User; +import com.quickpick.ureca.v1.ticket.domain.Ticket; +import com.quickpick.ureca.v1.user.domain.User; import jakarta.persistence.*; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; @Entity @Table @Getter +@Setter @NoArgsConstructor -public class UserTicket { +@AllArgsConstructor +public class UserTicketV1 { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -25,4 +29,8 @@ public class UserTicket { @JoinColumn(name = "ticket_id") private Ticket ticket; + public UserTicketV1(User user, Ticket ticket) { + this.user = user; + this.ticket = ticket; + } } \ No newline at end of file diff --git a/src/main/java/com/quickpick/ureca/v1/userticket/repository/UserTicketRepositoryV1.java b/src/main/java/com/quickpick/ureca/v1/userticket/repository/UserTicketRepositoryV1.java new file mode 100644 index 0000000..b7785b4 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/userticket/repository/UserTicketRepositoryV1.java @@ -0,0 +1,17 @@ +package com.quickpick.ureca.v1.userticket.repository; + +import com.quickpick.ureca.v1.userticket.domain.UserTicketV1; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface UserTicketRepositoryV1 extends JpaRepository { + + boolean existsByUser_UserIdAndTicket_TicketId(Long userId, Long ticketId); + + @Query(value = "SELECT 1 FROM user_ticket WHERE user_id = :userId AND ticket_id = :ticketId LIMIT 1", nativeQuery = true) + Integer existsUserTicketRaw(@Param("userId") Long userId, @Param("ticketId") Long ticketId); + + + +} diff --git a/src/main/java/com/quickpick/ureca/v1/userticket/repository/UserTicketShardingRepositoryV1.java b/src/main/java/com/quickpick/ureca/v1/userticket/repository/UserTicketShardingRepositoryV1.java new file mode 100644 index 0000000..20a2cac --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/userticket/repository/UserTicketShardingRepositoryV1.java @@ -0,0 +1,67 @@ +package com.quickpick.ureca.v1.userticket.repository; + +import com.quickpick.ureca.v1.userticket.domain.UserTicketV1; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class UserTicketShardingRepositoryV1 { + + private final EntityManager em; + + // Shard 선택 + private String getTableName(Long userId) { + int shard = (int)(userId % 10); + return "user_ticket_" + shard; + } + + public boolean exists(Long userId, Long ticketId) { + String tableName = getTableName(userId); + String sql = "SELECT 1 FROM " + tableName + + " WHERE user_id = :userId AND ticket_id = :ticketId LIMIT 1"; + + List result = em.createNativeQuery(sql) + .setParameter("userId", userId) + .setParameter("ticketId", ticketId) + .getResultList(); + + return !result.isEmpty(); + } + + public void saveIgnoreDuplicate(UserTicketV1 userTicketV1) { + String tableName = getTableName(userTicketV1.getUser().getUserId()); + + String sql = "INSERT IGNORE INTO " + tableName + " (user_id, ticket_id) " + + "VALUES (:userId, :ticketId)"; + + em.createNativeQuery(sql) + .setParameter("userId", userTicketV1.getUser().getUserId()) + .setParameter("ticketId", userTicketV1.getTicket().getTicketId()) + .executeUpdate(); + } + + public void save(UserTicketV1 userTicketV1) { + String tableName = getTableName(userTicketV1.getUser().getUserId()); + String sql = "INSERT INTO " + tableName + " (user_id, ticket_id) VALUES (:userId, :ticketId)"; + + em.createNativeQuery(sql) + .setParameter("userId", userTicketV1.getUser().getUserId()) + .setParameter("ticketId", userTicketV1.getTicket().getTicketId()) + .executeUpdate(); + } + + public void delete(Long userId, Long ticketId) { + String tableName = getTableName(userId); + String sql = "DELETE FROM " + tableName + " WHERE user_id = :userId AND ticket_id = :ticketId"; + + em.createNativeQuery(sql) + .setParameter("userId", userId) + .setParameter("ticketId", ticketId) + .executeUpdate(); + } + +} diff --git a/src/test/java/com/quickpick/ureca/v1/TicketReservationServiceTest.java b/src/test/java/com/quickpick/ureca/v1/TicketReservationServiceTest.java new file mode 100644 index 0000000..5282177 --- /dev/null +++ b/src/test/java/com/quickpick/ureca/v1/TicketReservationServiceTest.java @@ -0,0 +1,51 @@ +package com.quickpick.ureca.v1; + +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +class TicketReservationServiceTest { + +// @Autowired private TicketRepositoryV1 ticketRepositoryV1; +// @Autowired private UserRepository userRepository; +// @Autowired private UserTicketRepository userTicketRepository; +// @Autowired private ReserveServiceV1 reserveServiceV1; +// +// @Autowired +// private UserBulkInsertServiceV1 userBulkInsertServiceV1; +// +// @Test +// @DisplayName("동시에 1000개의 요청으로 100개의 티켓을 예약한다.") +// void PessimisticReservationTest() throws InterruptedException { +// int userCount = 30000; +// int ticketQuantity = 100; +// +// Ticket ticket = new Ticket("SKT 콘서트", ticketQuantity); +// ticketRepositoryV1.save(ticket); +// +// userBulkInsertServiceV1.insertUsersInBulk(userCount); +// +// ExecutorService executorService = Executors.newFixedThreadPool(32); +// CountDownLatch latch = new CountDownLatch(userCount); +// +// List allUsers = userRepository.findAll(); +// +// for (User user : allUsers) { +// executorService.submit(() -> { +// try { +// reserveServiceV1.reserveTicket(user.getUserId(), ticket.getTicketId()); +// } catch (Exception ignored) { +// } finally { +// latch.countDown(); +// } +// }); +// } +// +// latch.await(); +// +// List reservations = userTicketRepository.findAll(); +// System.out.println("총 예약 수: " + reservations.size()); +// assertEquals(ticketQuantity, reservations.size()); +// } +}