diff --git a/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java b/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java index fb907ee..03e4061 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java +++ b/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java @@ -17,7 +17,7 @@ public class WaitingQueueController { public void addQueue(@Valid AddQueueRequest request) { Long scheduleId = request.scheduleId(); String username = request.username(); - waitingQueueService.addQueue(scheduleId, username); + waitingQueueService.addWaitingQueue(scheduleId, username); System.out.println("연결 성공"); } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java b/src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java index c3472a1..e1c862b 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java +++ b/src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java @@ -1,9 +1,10 @@ package org.example.siljeun.domain.reservation.dto.request; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; public record AddQueueRequest( - @NotBlank + @NotNull Long scheduleId, @NotBlank String username diff --git a/src/main/java/org/example/siljeun/domain/reservation/dto/response/MyQueueInfoResponse.java b/src/main/java/org/example/siljeun/domain/reservation/dto/response/MyQueueInfoResponse.java index cacf256..19daeaa 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/dto/response/MyQueueInfoResponse.java +++ b/src/main/java/org/example/siljeun/domain/reservation/dto/response/MyQueueInfoResponse.java @@ -4,7 +4,7 @@ public record MyQueueInfoResponse( Long scheduleId, String username, Long rank, - Long acceptedRank + boolean isPassable ) { } diff --git a/src/main/java/org/example/siljeun/domain/reservation/exception/CustomException.java b/src/main/java/org/example/siljeun/domain/reservation/exception/CustomException.java index 6549d4c..a650626 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/exception/CustomException.java +++ b/src/main/java/org/example/siljeun/domain/reservation/exception/CustomException.java @@ -1,15 +1,14 @@ package org.example.siljeun.domain.reservation.exception; import lombok.Getter; -import org.springframework.http.HttpStatus; public class CustomException extends RuntimeException { @Getter - private HttpStatus errorCode; + private ErrorCode errorCode; public CustomException(ErrorCode errorCode) { super(errorCode.getMessage()); - this.errorCode = errorCode.getCode(); + this.errorCode = errorCode; } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java b/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java index 2790264..154a14d 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java +++ b/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java @@ -15,7 +15,7 @@ public class ReservationExceptionHandler { @ExceptionHandler(CustomException.class) public ResponseEntity> reservationExceptionHandler( CustomException e) { - return ResponseEntity.status(e.getErrorCode()) + return ResponseEntity.status(e.getErrorCode().getCode()) .body(ResponseDto.fail(e.getMessage())); } diff --git a/src/main/java/org/example/siljeun/domain/reservation/scheduler/CheckExpiredScheduler.java b/src/main/java/org/example/siljeun/domain/reservation/scheduler/CheckExpiredScheduler.java new file mode 100644 index 0000000..f520b0e --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/reservation/scheduler/CheckExpiredScheduler.java @@ -0,0 +1,99 @@ +package org.example.siljeun.domain.reservation.scheduler; + +import static org.example.siljeun.domain.reservation.service.WaitingQueueService.prefixKeyForSelecingQueue; +import static org.example.siljeun.domain.reservation.service.WaitingQueueService.prefixKeyForWaitingQueue; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.schedule.entity.Schedule; +import org.example.siljeun.domain.schedule.repository.ScheduleRepository; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CheckExpiredScheduler { + + private final StringRedisTemplate redisTemplate; + private final ScheduleRepository scheduleRepository; + + private final Set keys = new HashSet<>(); + + // 1시간마다 티켓팅 기간인 schedule을 keys에 저장 + @Scheduled(cron = "0 0 * * * *") + public void checkOpenedSchedule() { + + keys.clear(); + + List openedSchedules = scheduleRepository.findAllByStartTimeAfterAndTicketingStartTimeBefore( + LocalDateTime.now(), + LocalDateTime.now()).stream() + .map(Schedule::getId) + .toList(); + + try (Cursor cursor = redisTemplate.getConnectionFactory().getConnection() + .scan(ScanOptions.scanOptions().match(prefixKeyForSelecingQueue + "*") + .build())) { + while (cursor.hasNext()) { + String key = new String(cursor.next(), StandardCharsets.UTF_8); + String[] parts = key.split(":"); + Long scheduleId = Long.valueOf(parts[2]); + + if (openedSchedules.contains(scheduleId)) { + keys.add(key); + } + } + } + } + + // 1분마다 keys에 저장된 각 schedule의 대기열에서 TTL 만료인 유저 삭제 + @Scheduled(cron = "0 * * * * *") + public void checkExpiredUser() { + + for (String key : keys) { + redisTemplate.opsForZSet() + .removeRangeByScore(key, 0, System.currentTimeMillis()); + } + } + + // 1일마다 예매 종료된 공연은 sorted set에서 삭제 + @Scheduled(cron = "0 0 0 * * *") + public void deleteExpiredKey() { + + Set scheduleIdForDelete = new HashSet<>(); + + // sorted set에 저장된 scheduleId 추출 + try (Cursor cursor = redisTemplate.getConnectionFactory().getConnection() + .scan(ScanOptions.scanOptions().match(prefixKeyForSelecingQueue + "*") + .build())) { + while (cursor.hasNext()) { + String key = new String(cursor.next(), StandardCharsets.UTF_8); + String[] parts = key.split(":"); + Long scheduleId = Long.valueOf(parts[2]); + scheduleIdForDelete.add(scheduleId); + } + } + + // schedule.startTime < 현재 시각인 schedule 추출 + List schedules = scheduleRepository.findByIdInAndStartTimeBefore( + new ArrayList<>(scheduleIdForDelete), + LocalDateTime.now() + ); + + // sorted set 에서 제거 + schedules.stream() + .map(schedule -> prefixKeyForWaitingQueue + schedule.getId()) + .forEach(redisTemplate::delete); + schedules.stream() + .map(schedule -> prefixKeyForSelecingQueue + schedule.getId()) + .forEach(redisTemplate::delete); + } +} diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java index 3a03fa6..5b1767d 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java @@ -9,6 +9,7 @@ import org.example.siljeun.domain.reservation.repository.ReservationRepository; import org.example.siljeun.domain.schedule.repository.SeatScheduleInfoRepository; import org.example.siljeun.domain.seat.entity.SeatScheduleInfo; +import org.example.siljeun.domain.seat.enums.SeatStatus; import org.example.siljeun.domain.user.entity.User; import org.example.siljeun.domain.user.repository.UserRepository; import org.springframework.stereotype.Service; @@ -32,7 +33,8 @@ public void save(Long userId, Long seatScheduleInfoId) { Reservation reservation = new Reservation(user, seatScheduleInfo); reservationRepository.save(reservation); - waitingQueueService.deleteAtQueue(seatScheduleInfo.getSchedule().getId(), user.getUsername()); + waitingQueueService.deleteSelectingUser(seatScheduleInfo.getSchedule().getId(), + user.getUsername()); } @Transactional @@ -61,7 +63,7 @@ public void delete(String username, Long reservationId) { } reservationRepository.delete(reservation); - // Todo : 좌석 상태 변경 + reservation.getSeatScheduleInfo().updateSeatScheduleInfoStatus(SeatStatus.AVAILABLE); } public ReservationInfoResponse findById(String username, Long reservationId) { diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java b/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java index e73bab1..41be5ad 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java @@ -2,6 +2,7 @@ import jakarta.annotation.PostConstruct; import java.time.LocalDateTime; +import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.example.siljeun.domain.reservation.dto.response.MyQueueInfoResponse; @@ -13,19 +14,22 @@ import org.springframework.data.redis.core.ZSetOperations; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Slf4j @Service @RequiredArgsConstructor +@Transactional public class WaitingQueueService { private final StringRedisTemplate redisTemplate; private final SimpMessagingTemplate messagingTemplate; + private final ScheduleRepository scheduleRepository; private static final long ttlMillis = 900000L; // ttl 15분 private static final long acceptedRank = 1000L; // 좌석 선택 최대 수용 인원 1000명 - private static final String prefixKey = "queue:schedule:"; - private final ScheduleRepository scheduleRepository; + public static final String prefixKeyForWaitingQueue = "waiting:schedule:"; + public static final String prefixKeyForSelecingQueue = "selecting:schedule:"; // redis 연결 확인 @PostConstruct @@ -35,7 +39,8 @@ public void testRedisConnection() { } // 예매 대기 시작 - public void addQueue(Long scheduleId, String username) { + public void addWaitingQueue(Long scheduleId, String username) { + ZSetOperations zSet = redisTemplate.opsForZSet(); Schedule schedule = scheduleRepository.findById(scheduleId) .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_SCHEDULE)); @@ -44,49 +49,95 @@ public void addQueue(Long scheduleId, String username) { throw new CustomException(ErrorCode.NOT_TICKETING_TIME); } - String key = prefixKey + scheduleId; - long expiredAt = System.currentTimeMillis() + ttlMillis; - ZSetOperations zSet = redisTemplate.opsForZSet(); + String key = prefixKeyForWaitingQueue + scheduleId; + long createdAt = System.currentTimeMillis(); if (zSet.score(key, username) == null) { + zSet.add(key, username, createdAt); + } + + sendWaitingNumber(key, username, scheduleId); + } + + // 좌석 선택 중인 유저 큐에 insert (TTL 관리용 큐) + public void addSelectingQueue(Long scheduleId, String username) { + ZSetOperations zSet = redisTemplate.opsForZSet(); + + String key = prefixKeyForSelecingQueue + scheduleId; + Long expiredAt = System.currentTimeMillis() + ttlMillis; + + if (zSet.score(prefixKeyForSelecingQueue + scheduleId, username) == null) { zSet.add(key, username, expiredAt); } + } + + // 대기 끝 or 소켓 연결 해제되면 대기열에서 삭제 + public void deleteWaitingUser(Long scheduleId, String username) { + String key = prefixKeyForWaitingQueue + scheduleId; + redisTemplate.opsForZSet().remove(key, username); + } + + // 좌석 선택 완료 or 소켓 연결 해제 or TTL 만료되면 큐에서 삭제 + public void deleteSelectingUser(Long scheduleId, String username) { + String key = prefixKeyForSelecingQueue + scheduleId; + redisTemplate.opsForZSet().remove(key, username); + sendAllWaitingNumber(scheduleId); + } + + // 정상적인 경로로 좌석 선택 api 호출했는지 검증 + public boolean hasPassedWaitingQueue(Long scheduleId, String username) { + return + redisTemplate.opsForZSet().score(prefixKeyForSelecingQueue + scheduleId, username) != null; + } + + // 대기중인 특정 사용자에게 랭킹 및 대기번호 전송 + public void sendWaitingNumber(String key, String username, Long scheduleId) { + ZSetOperations zSet = redisTemplate.opsForZSet(); Long rank = zSet.rank(key, username); + if (rank == null) { throw new CustomException(ErrorCode.QUEUE_INSERT_FAIL); } + rank = rank + 1; - String destination = "/topic/queue/" + scheduleId + "/" + username; - MyQueueInfoResponse response = new MyQueueInfoResponse(scheduleId, username, rank, - acceptedRank); - messagingTemplate.convertAndSend(destination, response); - } + // 내 순위와 현재 좌석 선택 중인 사용자 수의 합이 수용 인원보다 적으면 대기 X + Long selectingQueueSize = zSet.size(prefixKeyForSelecingQueue + scheduleId); + selectingQueueSize = (selectingQueueSize == null) ? 0 : selectingQueueSize; - // 기존 유저가 좌석 선택 완료 or 소켓 연결 종료하면 대기열에서 삭제 - public void deleteAtQueue(Long scheduleId, String username) { - redisTemplate.opsForZSet().remove(prefixKey + scheduleId, username); - log.info("Disconnected and removed Schedule: {}, User: {}", scheduleId, username); + if (rank + selectingQueueSize <= acceptedRank) { + String destination = "/topic/queue/" + scheduleId + "/" + username; + MyQueueInfoResponse response = new MyQueueInfoResponse(scheduleId, username, rank, + true); + messagingTemplate.convertAndSend(destination, response); - // TTL 만료된 데이터 삭제 - redisTemplate.opsForZSet() - .removeRangeByScore(prefixKey + scheduleId, 0, System.currentTimeMillis()); + addSelectingQueue(scheduleId, username); + deleteWaitingUser(scheduleId, username); - // rank() 재실행해서 변경된 대기번호 클라이언트에 전송 - Long rank = redisTemplate.opsForZSet().rank(prefixKey + scheduleId, username); - rank = (rank != null) ? rank + 1 : -1; + return; + } String destination = "/topic/queue/" + scheduleId + "/" + username; MyQueueInfoResponse response = new MyQueueInfoResponse(scheduleId, username, rank, - acceptedRank); + false); messagingTemplate.convertAndSend(destination, response); } - // sorted set에 해당 scheduleId, userId를 가지는 데이터가 존재하는지 확인 - // Todo : 좌석 선택 메서드 안에서 호출(정상 경로로 접근했는지 검증 필요) - public boolean checkQueue(Long scheduleId, String username) { - boolean exists = redisTemplate.opsForZSet().score(prefixKey + scheduleId, username) != null; - return exists; + // 대기중인 모든 사용자에게 랭킹 및 대기번호 전송 + public void sendAllWaitingNumber(Long scheduleId) { + String key = prefixKeyForWaitingQueue + scheduleId; + + // for문이나 stream으로 scheduleId에 해당하는 value값 리스트 추출 + Set usernames = redisTemplate.opsForZSet().range(key, 0, -1); + + if (usernames == null || usernames.isEmpty()) { + return; + } + + // 해당 유저들한테 메세지 전송 + for (String username : usernames) { + sendWaitingNumber(key, username, scheduleId); + } } } diff --git a/src/main/java/org/example/siljeun/domain/schedule/controller/SeatScheduleInfoController.java b/src/main/java/org/example/siljeun/domain/schedule/controller/SeatScheduleInfoController.java index 9473dcb..c862976 100644 --- a/src/main/java/org/example/siljeun/domain/schedule/controller/SeatScheduleInfoController.java +++ b/src/main/java/org/example/siljeun/domain/schedule/controller/SeatScheduleInfoController.java @@ -10,28 +10,28 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; -import java.util.List; import java.util.Map; @Controller @RequiredArgsConstructor public class SeatScheduleInfoController { - private final SeatScheduleInfoService seatScheduleInfoService; + private final SeatScheduleInfoService seatScheduleInfoService; - @PostMapping("/seat-schedule-info/{seatScheduleInfoId}") - public ResponseEntity selectSeat( - @PathVariable Long seatScheduleInfoId, - @AuthenticationPrincipal PrincipalDetails userDetails - ){ - seatScheduleInfoService.selectSeat(userDetails.getUserId(), seatScheduleInfoId); - return ResponseEntity.ok("좌석이 선택되었습니다."); - } + @PostMapping("/seat-schedule-info/{seatScheduleInfoId}") + public ResponseEntity selectSeat( + @PathVariable Long seatScheduleInfoId, + @AuthenticationPrincipal PrincipalDetails userDetails + ) { + seatScheduleInfoService.selectSeat(userDetails.getUserId(), userDetails.getUsername(), + seatScheduleInfoId); + return ResponseEntity.ok("좌석이 선택되었습니다."); + } - @GetMapping("/schedule/{scheduleId}/seat-schedule-info") - public ResponseEntity> getSeatScheduleInfos( - @PathVariable Long scheduleId - ){ - return ResponseEntity.ok(seatScheduleInfoService.getSeatStatusMap(scheduleId)); - } + @GetMapping("/schedule/{scheduleId}/seat-schedule-info") + public ResponseEntity> getSeatScheduleInfos( + @PathVariable Long scheduleId + ) { + return ResponseEntity.ok(seatScheduleInfoService.getSeatStatusMap(scheduleId)); + } } diff --git a/src/main/java/org/example/siljeun/domain/schedule/repository/ScheduleRepository.java b/src/main/java/org/example/siljeun/domain/schedule/repository/ScheduleRepository.java index f95d9fe..fa3643b 100644 --- a/src/main/java/org/example/siljeun/domain/schedule/repository/ScheduleRepository.java +++ b/src/main/java/org/example/siljeun/domain/schedule/repository/ScheduleRepository.java @@ -1,6 +1,7 @@ package org.example.siljeun.domain.schedule.repository; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import org.example.siljeun.domain.schedule.entity.Schedule; @@ -10,7 +11,13 @@ public interface ScheduleRepository extends JpaRepository, Sched List findByConcertId(Long concertId); - List findAllByTicketingStartTimeBetween(LocalDateTime ticketingStartTimeAfter, LocalDateTime ticketingStartTimeBefore); + List findAllByTicketingStartTimeBetween(LocalDateTime ticketingStartTimeAfter, + LocalDateTime ticketingStartTimeBefore); Optional findById(Long id); + + List findAllByStartTimeAfterAndTicketingStartTimeBefore(LocalDateTime now, + LocalDateTime now1); + + List findByIdInAndStartTimeBefore(ArrayList longs, LocalDateTime now); } diff --git a/src/main/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoService.java b/src/main/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoService.java index 0e5bfdc..9f83f05 100644 --- a/src/main/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoService.java +++ b/src/main/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoService.java @@ -1,7 +1,12 @@ package org.example.siljeun.domain.schedule.service; import jakarta.persistence.EntityNotFoundException; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import lombok.extern.slf4j.Slf4j; +import org.example.siljeun.domain.reservation.service.WaitingQueueService; import org.example.siljeun.domain.schedule.entity.Schedule; import org.example.siljeun.domain.schedule.repository.ScheduleRepository; import org.example.siljeun.domain.schedule.repository.SeatScheduleInfoRepository; @@ -12,80 +17,90 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; -import java.time.Duration; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - @Slf4j @Service public class SeatScheduleInfoService { - private final SeatScheduleInfoRepository seatScheduleInfoRepository; - private final ScheduleRepository scheduleRepository; - private final RedisTemplate redisSeatUserTemplate; - private final RedisTemplate redisStatusTemplate; - - public SeatScheduleInfoService( - SeatScheduleInfoRepository seatScheduleInfoRepository, - ScheduleRepository scheduleRepository, - @Qualifier("redisLongTemplate") RedisTemplate redisSeatUserTemplate, - @Qualifier("redisStringTemplate") RedisTemplate redisStatusTemplate - ){ - this.seatScheduleInfoRepository = seatScheduleInfoRepository; - this.scheduleRepository = scheduleRepository; - this.redisSeatUserTemplate = redisSeatUserTemplate; - this.redisStatusTemplate = redisStatusTemplate; - } - @DistributedLock(key = "'seat:' + #seatScheduleInfoId") - public void selectSeat(Long userId, Long seatScheduleInfoId) { - - SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId). - orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다.")); + private final SeatScheduleInfoRepository seatScheduleInfoRepository; + private final ScheduleRepository scheduleRepository; + private final RedisTemplate redisSeatUserTemplate; + private final RedisTemplate redisStatusTemplate; + private final WaitingQueueService waitingQueueService; + + public SeatScheduleInfoService( + SeatScheduleInfoRepository seatScheduleInfoRepository, + ScheduleRepository scheduleRepository, + @Qualifier("redisLongTemplate") RedisTemplate redisSeatUserTemplate, + @Qualifier("redisStringTemplate") RedisTemplate redisStatusTemplate, + WaitingQueueService waitingQueueService) { + this.seatScheduleInfoRepository = seatScheduleInfoRepository; + this.scheduleRepository = scheduleRepository; + this.redisSeatUserTemplate = redisSeatUserTemplate; + this.redisStatusTemplate = redisStatusTemplate; + this.waitingQueueService = waitingQueueService; + } + + @Transactional + @DistributedLock(key = "'seat:' + #seatScheduleInfoId") + public void selectSeat(Long userId, String username, Long seatScheduleInfoId) { + + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId). + orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다.")); + + boolean hasPassedQueue = waitingQueueService.hasPassedWaitingQueue( + seatScheduleInfo.getSchedule().getId(), username); + if (!hasPassedQueue) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "정상적인 접근이 아닙니다."); + } - if (!seatScheduleInfo.isAvailable()) { - //log.info("이미 선점된 좌석입니다."); - throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 선점된 좌석입니다."); - } + if (!seatScheduleInfo.isAvailable()) { + //log.info("이미 선점된 좌석입니다."); + throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 선점된 좌석입니다."); + } - seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.SELECTED); - seatScheduleInfoRepository.save(seatScheduleInfo); + seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.SELECTED); + seatScheduleInfoRepository.save(seatScheduleInfo); - String redisKey = "seat:" + seatScheduleInfoId; - redisSeatUserTemplate.opsForValue().set(redisKey, userId, Duration.ofMinutes(5)); + String redisKey = "seat:" + seatScheduleInfoId; + redisSeatUserTemplate.opsForValue().set(redisKey, userId, Duration.ofMinutes(5)); - String redisStatusKey = "seatStatus:" + seatScheduleInfoId; - redisStatusTemplate.opsForValue().set(redisStatusKey, seatScheduleInfo.getStatus().name(), Duration.ofMinutes(5)); - } + String redisStatusKey = "seatStatus:" + seatScheduleInfoId; + redisStatusTemplate.opsForValue() + .set(redisStatusKey, seatScheduleInfo.getStatus().name(), Duration.ofMinutes(5)); - public Map getSeatStatusMap(Long scheduleId) { + waitingQueueService.deleteSelectingUser(seatScheduleInfo.getSchedule().getId(), username); + } - Schedule schedule = scheduleRepository.findById(scheduleId) - .orElseThrow(() -> new EntityNotFoundException("해당 회차가 존재하지 않습니다.")); + public Map getSeatStatusMap(Long scheduleId) { - List seatScheduleInfos = - seatScheduleInfoRepository.findAllBySchedule(schedule); + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(() -> new EntityNotFoundException("해당 회차가 존재하지 않습니다.")); - Map result = new HashMap<>(); + List seatScheduleInfos = + seatScheduleInfoRepository.findAllBySchedule(schedule); - for (SeatScheduleInfo info : seatScheduleInfos) { - String redisKey = "seatStatus:" + info.getId(); - String redisStatus = redisStatusTemplate.opsForValue().get(redisKey); + Map result = new HashMap<>(); - String status; - if (redisStatus != null) { - status = redisStatus; - } else if (info.getStatus() == SeatStatus.SELECTED) { //TTL에 의해서 Redis에서는 만료되었으나 DB에 Selected로 저장된 경우 - status = SeatStatus.AVAILABLE.name(); - } else { - status = info.getStatus().name(); - } + for (SeatScheduleInfo info : seatScheduleInfos) { + String redisKey = "seatStatus:" + info.getId(); + String redisStatus = redisStatusTemplate.opsForValue().get(redisKey); - result.put("seatScheduleInfo-" + info.getId().toString(), status); - } + String status; + if (redisStatus != null) { + status = redisStatus; + } else if (info.getStatus() + == SeatStatus.SELECTED) { //TTL에 의해서 Redis에서는 만료되었으나 DB에 Selected로 저장된 경우 + status = SeatStatus.AVAILABLE.name(); + } else { + status = info.getStatus().name(); + } - return result; + result.put("seatScheduleInfo-" + info.getId().toString(), status); } + + return result; + } } diff --git a/src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java b/src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java index 4a37340..1ae8dfe 100644 --- a/src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java +++ b/src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java @@ -21,7 +21,8 @@ public void handleDisconnect(SessionDisconnectEvent event) { Long scheduleId = Long.valueOf((String) accessor.getSessionAttributes().get("scheduleId")); if (username != null && scheduleId != null) { - waitingQueueService.deleteAtQueue(scheduleId, username); + waitingQueueService.deleteWaitingUser(scheduleId, username); + waitingQueueService.deleteSelectingUser(scheduleId, username); } } } diff --git a/src/test/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoServiceTest.java b/src/test/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoServiceTest.java index e225c9d..8454bad 100644 --- a/src/test/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoServiceTest.java +++ b/src/test/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoServiceTest.java @@ -1,5 +1,16 @@ package org.example.siljeun.domain.schedule.service; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.IntStream; import org.example.siljeun.domain.concert.entity.Concert; import org.example.siljeun.domain.concert.entity.ConcertCategory; import org.example.siljeun.domain.concert.repository.ConcertRepository; @@ -21,101 +32,94 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.stream.IntStream; - -import static org.junit.jupiter.api.Assertions.*; - @SpringBootTest @AutoConfigureMockMvc @TestInstance(TestInstance.Lifecycle.PER_CLASS) class SeatScheduleInfoServiceTest { - @Autowired - private SeatScheduleInfoService seatScheduleInfoService; - - @Autowired - private SeatScheduleInfoRepository seatScheduleInfoRepository; - - @Autowired - private VenueRepository venueRepository; - - @Autowired - private VenueSeatRepository venueSeatRepository; - - @Autowired - private ScheduleRepository scheduleRepository; - - @Autowired - private ConcertRepository concertRepository; - - @Autowired - @Qualifier("redisLongTemplate") - private RedisTemplate redisTemplate; - - private Seat seat; - private Schedule schedule; - - @BeforeEach - void setUp() { - redisTemplate.getConnectionFactory().getConnection().flushAll(); - Venue venue = venueRepository.save(new Venue("샤롯데씨어터", "잠실 어딘가", 1)); - seat = venueSeatRepository.save(new Seat(venue, "A", "1", "1", "VIP", 180000)); - Concert concert = concertRepository.save(new Concert("위키드", "엘파바와 글린다", ConcertCategory.MUSICAL, venue, 1000)); - schedule = scheduleRepository.save(new Schedule(concert, LocalDateTime.of(2025, 6, 6, 14, 30), LocalDateTime.of(2025, 5, 6, 10, 0))); - } - - @Test - @DisplayName("동일 좌석 동시 요청: 1명만 성공하고 나머지는 선점 메시지") - void sameSeatConcurrentAccessTest() throws InterruptedException { - // given - SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.save(new SeatScheduleInfo(seat, schedule, SeatStatus.AVAILABLE, seat.getDefaultGrade(), seat.getDefaultPrice())); - Long seatScheduleInfoId = seatScheduleInfo.getId(); - int totalThreads = 1000; - ExecutorService executor = Executors.newFixedThreadPool(100); - CountDownLatch latch = new CountDownLatch(totalThreads); - List resultMessages = Collections.synchronizedList(new ArrayList<>()); - - // when - IntStream.range(0, totalThreads).forEach(i -> { - executor.submit(() -> { - try { - seatScheduleInfoService.selectSeat((long) i + 1, seatScheduleInfoId); - resultMessages.add("SUCCESS"); - } catch (ResponseStatusException e) { - resultMessages.add(e.getReason()); - } finally { - latch.countDown(); - } - }); - }); - - latch.await(); - - // then - long successCount = resultMessages.stream().filter("SUCCESS"::equals).count(); - long conflictCount = resultMessages.stream().filter("이미 선점된 좌석입니다."::equals).count(); - - System.out.println("\n성공 요청 수: " + successCount); - System.out.println("실패 요청 수: " + conflictCount); - - assertEquals(1, successCount); - assertEquals(totalThreads - 1, conflictCount); - - SeatScheduleInfo updated = seatScheduleInfoRepository.findById(seatScheduleInfoId).orElseThrow(); - assertEquals(SeatStatus.SELECTED, updated.getStatus()); - - Long storedUserId = redisTemplate.opsForValue().get("seat:" + seatScheduleInfoId); - assertNotNull(storedUserId); - System.out.println("Redis에 저장된 유저 ID: " + storedUserId); - } + @Autowired + private SeatScheduleInfoService seatScheduleInfoService; + + @Autowired + private SeatScheduleInfoRepository seatScheduleInfoRepository; + + @Autowired + private VenueRepository venueRepository; + + @Autowired + private VenueSeatRepository venueSeatRepository; + + @Autowired + private ScheduleRepository scheduleRepository; + + @Autowired + private ConcertRepository concertRepository; + + @Autowired + @Qualifier("redisLongTemplate") + private RedisTemplate redisTemplate; + + private Seat seat; + private Schedule schedule; + + @BeforeEach + void setUp() { + redisTemplate.getConnectionFactory().getConnection().flushAll(); + Venue venue = venueRepository.save(new Venue("샤롯데씨어터", "잠실 어딘가", 1)); + seat = venueSeatRepository.save(new Seat(venue, "A", "1", "1", "VIP", 180000)); + Concert concert = concertRepository.save( + new Concert("위키드", "엘파바와 글린다", ConcertCategory.MUSICAL, venue, 1000)); + schedule = scheduleRepository.save(new Schedule(concert, LocalDateTime.of(2025, 6, 6, 14, 30), + LocalDateTime.of(2025, 5, 6, 10, 0))); + } + + @Test + @DisplayName("동일 좌석 동시 요청: 1명만 성공하고 나머지는 선점 메시지") + void sameSeatConcurrentAccessTest() throws InterruptedException { + // given + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.save( + new SeatScheduleInfo(seat, schedule, SeatStatus.AVAILABLE, seat.getDefaultGrade(), + seat.getDefaultPrice())); + Long seatScheduleInfoId = seatScheduleInfo.getId(); + int totalThreads = 1000; + ExecutorService executor = Executors.newFixedThreadPool(100); + CountDownLatch latch = new CountDownLatch(totalThreads); + List resultMessages = Collections.synchronizedList(new ArrayList<>()); + + // when + IntStream.range(0, totalThreads).forEach(i -> { + executor.submit(() -> { + try { + seatScheduleInfoService.selectSeat((long) i + 1, "testUser", seatScheduleInfoId); + resultMessages.add("SUCCESS"); + } catch (ResponseStatusException e) { + resultMessages.add(e.getReason()); + } finally { + latch.countDown(); + } + }); + }); + + latch.await(); + + // then + long successCount = resultMessages.stream().filter("SUCCESS"::equals).count(); + long conflictCount = resultMessages.stream().filter("이미 선점된 좌석입니다."::equals).count(); + + System.out.println("\n성공 요청 수: " + successCount); + System.out.println("실패 요청 수: " + conflictCount); + + assertEquals(1, successCount); + assertEquals(totalThreads - 1, conflictCount); + + SeatScheduleInfo updated = seatScheduleInfoRepository.findById(seatScheduleInfoId) + .orElseThrow(); + assertEquals(SeatStatus.SELECTED, updated.getStatus()); + + Long storedUserId = redisTemplate.opsForValue().get("seat:" + seatScheduleInfoId); + assertNotNull(storedUserId); + System.out.println("Redis에 저장된 유저 ID: " + storedUserId); + } } \ No newline at end of file