From be34533687b3e9eb3723bdedffd5191dd40d6afd Mon Sep 17 00:00:00 2001 From: crocusia <132359536+crocusia@users.noreply.github.com> Date: Thu, 22 May 2025 15:15:57 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat=20:=20=ED=9A=8C=EC=B0=A8=EB=B3=84=20?= =?UTF-8?q?=EC=A2=8C=EC=84=9D=20=EC=A0=95=EB=B3=B4=20Redis=20=EC=A0=81?= =?UTF-8?q?=EC=9E=AC=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=A7=81=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SeatScheduleInfoController.java | 11 ++++ .../repository/ScheduleRepository.java | 3 ++ .../SeatScheduleInfoRepository.java | 4 ++ .../scheduler/TicketingRedisScheduler.java | 50 +++++++++++++++++++ 4 files changed, 68 insertions(+) create mode 100644 src/main/java/org/example/siljeun/domain/schedule/scheduler/TicketingRedisScheduler.java 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 1beca6b..9a6ec66 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 @@ -2,12 +2,16 @@ import lombok.RequiredArgsConstructor; import org.example.siljeun.domain.schedule.service.SeatScheduleInfoService; +import org.example.siljeun.domain.seat.dto.response.SeatScheduleInfoResponse; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import java.util.List; + @Controller @RequiredArgsConstructor public class SeatScheduleInfoController { @@ -22,4 +26,11 @@ public ResponseEntity selectSeat( return ResponseEntity.ok("좌석이 선택되었습니다."); } + +// @GetMapping("/schedule/{scheduleId}/seat-schedule-info") +// public ResponseEntity> getSeatScheduleInfos( +// @PathVariable Long scheduleId +// ){ +// return ResponseEntity.ok(new List seats); +// } } 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 32a9266..4969b50 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,5 +1,6 @@ package org.example.siljeun.domain.schedule.repository; +import java.time.LocalDateTime; import java.util.List; import org.example.siljeun.domain.schedule.entity.Schedule; import org.springframework.data.jpa.repository.JpaRepository; @@ -7,4 +8,6 @@ public interface ScheduleRepository extends JpaRepository, ScheduleQueryRepository { List findByConcertId(Long concertId); + + List findAllByTicketingStartTimeBetween(LocalDateTime ticketingStartTimeAfter, LocalDateTime ticketingStartTimeBefore); } diff --git a/src/main/java/org/example/siljeun/domain/schedule/repository/SeatScheduleInfoRepository.java b/src/main/java/org/example/siljeun/domain/schedule/repository/SeatScheduleInfoRepository.java index 50d55f4..20f047d 100644 --- a/src/main/java/org/example/siljeun/domain/schedule/repository/SeatScheduleInfoRepository.java +++ b/src/main/java/org/example/siljeun/domain/schedule/repository/SeatScheduleInfoRepository.java @@ -1,8 +1,12 @@ package org.example.siljeun.domain.schedule.repository; +import org.example.siljeun.domain.schedule.entity.Schedule; import org.example.siljeun.domain.seat.entity.SeatScheduleInfo; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface SeatScheduleInfoRepository extends JpaRepository { + List findAllBySchedule(Schedule schedule); } diff --git a/src/main/java/org/example/siljeun/domain/schedule/scheduler/TicketingRedisScheduler.java b/src/main/java/org/example/siljeun/domain/schedule/scheduler/TicketingRedisScheduler.java new file mode 100644 index 0000000..2ba9492 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/schedule/scheduler/TicketingRedisScheduler.java @@ -0,0 +1,50 @@ +package org.example.siljeun.domain.schedule.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.siljeun.domain.schedule.entity.Schedule; +import org.example.siljeun.domain.schedule.repository.ScheduleRepository; +import org.example.siljeun.domain.schedule.repository.SeatScheduleInfoRepository; +import org.example.siljeun.domain.seat.entity.SeatScheduleInfo; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TicketingRedisScheduler { + private final ScheduleRepository scheduleRepository; + private final SeatScheduleInfoRepository seatScheduleInfoRepository; + private final RedisTemplate redisTemplate; + + @Scheduled(fixedRate = 60_000) + public void loadSeatStatusToRedis() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime fiveMinutesLater = now.plusMinutes(5); //티켓팅 시작 시간이 임박한 회차에 대해 미리 Redis에 정보 적재 + + List openedSchedules = scheduleRepository.findAllByTicketingStartTimeBetween(now, fiveMinutesLater); + + for (Schedule schedule : openedSchedules) { + Long scheduleId = schedule.getId(); + String redisKey = "seatStatus:" + scheduleId; + + List seatScheduleInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); + + Map seatStatusMap = seatScheduleInfos.stream() + .collect(Collectors.toMap( + seat -> String.valueOf(seat.getId()), + seat -> seat.getStatus().name() + )); + + redisTemplate.opsForHash().putAll(redisKey, seatStatusMap); + log.info("✅ Redis에 좌석 상태 저장 완료 [key: {}] seatCount: {}", redisKey, seatStatusMap.size()); + } + } + +} From 565da36a60c2654792c6c9e4ed954ce8305c5a7d Mon Sep 17 00:00:00 2001 From: crocusia <132359536+crocusia@users.noreply.github.com> Date: Thu, 22 May 2025 17:25:05 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat=20:=20Redis=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=A2=8C=EC=84=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?(DB=EC=99=80=EC=9D=98=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=ED=95=84=EC=9A=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SeatScheduleInfoController.java | 13 ++--- .../scheduler/TicketingRedisScheduler.java | 22 ++++---- .../service/SeatScheduleInfoService.java | 54 +++++++++++++++++-- .../siljeun/global/config/RedisConfig.java | 9 ++++ .../service/SeatScheduleInfoServiceTest.java | 2 +- 5 files changed, 76 insertions(+), 24 deletions(-) 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 9a6ec66..319fb0f 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 @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import java.util.List; +import java.util.Map; @Controller @RequiredArgsConstructor @@ -27,10 +28,10 @@ public ResponseEntity selectSeat( return ResponseEntity.ok("좌석이 선택되었습니다."); } -// @GetMapping("/schedule/{scheduleId}/seat-schedule-info") -// public ResponseEntity> getSeatScheduleInfos( -// @PathVariable Long scheduleId -// ){ -// return ResponseEntity.ok(new List seats); -// } + @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/scheduler/TicketingRedisScheduler.java b/src/main/java/org/example/siljeun/domain/schedule/scheduler/TicketingRedisScheduler.java index 2ba9492..dd3d486 100644 --- a/src/main/java/org/example/siljeun/domain/schedule/scheduler/TicketingRedisScheduler.java +++ b/src/main/java/org/example/siljeun/domain/schedule/scheduler/TicketingRedisScheduler.java @@ -6,6 +6,7 @@ import org.example.siljeun.domain.schedule.repository.ScheduleRepository; import org.example.siljeun.domain.schedule.repository.SeatScheduleInfoRepository; import org.example.siljeun.domain.seat.entity.SeatScheduleInfo; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -21,7 +22,9 @@ public class TicketingRedisScheduler { private final ScheduleRepository scheduleRepository; private final SeatScheduleInfoRepository seatScheduleInfoRepository; - private final RedisTemplate redisTemplate; + + @Qualifier("redisStatusTemplate") + private final RedisTemplate redisStatusTemplate; @Scheduled(fixedRate = 60_000) public void loadSeatStatusToRedis() { @@ -31,20 +34,13 @@ public void loadSeatStatusToRedis() { List openedSchedules = scheduleRepository.findAllByTicketingStartTimeBetween(now, fiveMinutesLater); for (Schedule schedule : openedSchedules) { - Long scheduleId = schedule.getId(); - String redisKey = "seatStatus:" + scheduleId; - List seatScheduleInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); - Map seatStatusMap = seatScheduleInfos.stream() - .collect(Collectors.toMap( - seat -> String.valueOf(seat.getId()), - seat -> seat.getStatus().name() - )); - - redisTemplate.opsForHash().putAll(redisKey, seatStatusMap); - log.info("✅ Redis에 좌석 상태 저장 완료 [key: {}] seatCount: {}", redisKey, seatStatusMap.size()); + for(SeatScheduleInfo seatScheduleInfo : seatScheduleInfos){ + String key = "seatStatus:" + seatScheduleInfo.getId().toString(); + String value = seatScheduleInfo.getStatus().name(); + redisStatusTemplate.opsForValue().set(key, value); + } } } - } 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 5a6a92d..0cc13e0 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,40 +1,86 @@ package org.example.siljeun.domain.schedule.service; +import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.example.siljeun.domain.schedule.entity.Schedule; +import org.example.siljeun.domain.schedule.repository.ScheduleRepository; import org.example.siljeun.domain.schedule.repository.SeatScheduleInfoRepository; +import org.example.siljeun.domain.seat.entity.QSeatScheduleInfo; import org.example.siljeun.domain.seat.entity.SeatScheduleInfo; import org.example.siljeun.domain.seat.enums.SeatStatus; import org.example.siljeun.global.lock.DistributedLock; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; import java.time.Duration; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + @Slf4j @Service @RequiredArgsConstructor public class SeatScheduleInfoService { private final SeatScheduleInfoRepository seatScheduleInfoRepository; + private final ScheduleRepository scheduleRepository; + private final RedisTemplate redisTemplate; + @Qualifier("redisStatusTemplate") + private final RedisTemplate redisStatusTemplate; + @DistributedLock(key = "'seat:' + #seatScheduleInfoId") public void selectSeat(Long userId, Long seatScheduleInfoId) { - SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 회차의 좌석 정보를 찾을 수 없습니다.")); + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId). + orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다.")); if (!seatScheduleInfo.isAvailable()) { //log.info("이미 선점된 좌석입니다."); throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 선점된 좌석입니다."); } - seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.HOLD); + seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.SELECTED); seatScheduleInfoRepository.save(seatScheduleInfo); String redisKey = "seat:" + seatScheduleInfoId; redisTemplate.opsForValue().set(redisKey, userId, Duration.ofMinutes(5)); - Object redisValue = redisTemplate.opsForValue().get(redisKey); - //log.info("좌석 선택 성공 [redis 저장 : {} = {}]", redisKey, redisValue); + + String redisStatusKey = "seatStatus:" + seatScheduleInfoId; + redisStatusTemplate.opsForValue().set(redisStatusKey, seatScheduleInfo.getStatus().name(), Duration.ofMinutes(5)); + } + + public Map getSeatStatusMap(Long scheduleId) { + + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(() -> new EntityNotFoundException("해당 회차가 존재하지 않습니다.")); + + List seatScheduleInfos = + seatScheduleInfoRepository.findAllBySchedule(schedule); + + Map result = new HashMap<>(); + + for (SeatScheduleInfo info : seatScheduleInfos) { + String redisKey = "seatStatus:" + info.getId(); + String redisStatus = redisStatusTemplate.opsForValue().get(redisKey); + + 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(); + } + + result.put("seatScheduleInfo-" + info.getId().toString(), status); + } + + return result; } } diff --git a/src/main/java/org/example/siljeun/global/config/RedisConfig.java b/src/main/java/org/example/siljeun/global/config/RedisConfig.java index a42ca70..fe361b9 100644 --- a/src/main/java/org/example/siljeun/global/config/RedisConfig.java +++ b/src/main/java/org/example/siljeun/global/config/RedisConfig.java @@ -39,4 +39,13 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connecti redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Long.class)); // Long 값 직렬화 return redisTemplate; } + + @Bean + public RedisTemplate redisStatusTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(connectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + return redisTemplate; + } } 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 bec536c..569775f 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 @@ -110,7 +110,7 @@ void sameSeatConcurrentAccessTest() throws InterruptedException { assertEquals(totalThreads - 1, conflictCount); SeatScheduleInfo updated = seatScheduleInfoRepository.findById(seatScheduleInfoId).orElseThrow(); - assertEquals(SeatStatus.HOLD, updated.getStatus()); + assertEquals(SeatStatus.SELECTED, updated.getStatus()); Long storedUserId = redisTemplate.opsForValue().get("seat:" + seatScheduleInfoId); assertNotNull(storedUserId);