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..a450ff9 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,19 @@ import lombok.RequiredArgsConstructor; import org.example.siljeun.domain.schedule.service.SeatScheduleInfoService; +import org.example.siljeun.domain.seat.dto.response.SeatScheduleInfoResponse; +import org.example.siljeun.global.security.CustomUserDetails; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; 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; +import java.util.Map; + @Controller @RequiredArgsConstructor public class SeatScheduleInfoController { @@ -16,10 +23,17 @@ public class SeatScheduleInfoController { @PostMapping("/seat-schedule-info/{seatScheduleInfoId}") public ResponseEntity selectSeat( - @PathVariable Long seatScheduleInfoId + @PathVariable Long seatScheduleInfoId, + @AuthenticationPrincipal CustomUserDetails userDetails ){ - seatScheduleInfoService.selectSeat(1L, seatScheduleInfoId); - + seatScheduleInfoService.selectSeat(userDetails.getUserId(), seatScheduleInfoId); return ResponseEntity.ok("좌석이 선택되었습니다."); } + + @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 3d52d1e..f95d9fe 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 java.util.Optional; import org.example.siljeun.domain.schedule.entity.Schedule; @@ -9,5 +10,7 @@ public interface ScheduleRepository extends JpaRepository, Sched List findByConcertId(Long concertId); + List findAllByTicketingStartTimeBetween(LocalDateTime ticketingStartTimeAfter, LocalDateTime ticketingStartTimeBefore); + Optional findById(Long id); } 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..4aba080 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/schedule/scheduler/TicketingRedisScheduler.java @@ -0,0 +1,53 @@ +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.beans.factory.annotation.Qualifier; +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 +public class TicketingRedisScheduler { + private final ScheduleRepository scheduleRepository; + private final SeatScheduleInfoRepository seatScheduleInfoRepository; + private final RedisTemplate redisStatusTemplate; + + public TicketingRedisScheduler( + ScheduleRepository scheduleRepository, + SeatScheduleInfoRepository seatScheduleInfoRepository, + @Qualifier("redisStringTemplate") RedisTemplate redisStatusTemplate + ){ + this.scheduleRepository = scheduleRepository; + this.seatScheduleInfoRepository = seatScheduleInfoRepository; + this.redisStatusTemplate = redisStatusTemplate; + } + + @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) { + List seatScheduleInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); + + 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..0e5bfdc 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,91 @@ package org.example.siljeun.domain.schedule.service; -import lombok.RequiredArgsConstructor; +import jakarta.persistence.EntityNotFoundException; 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.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.List; +import java.util.Map; + @Slf4j @Service -@RequiredArgsConstructor public class SeatScheduleInfoService { private final SeatScheduleInfoRepository seatScheduleInfoRepository; - private final RedisTemplate redisTemplate; + 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 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); + redisSeatUserTemplate.opsForValue().set(redisKey, userId, Duration.ofMinutes(5)); + + 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 9b822df..bd2c0e8 100644 --- a/src/main/java/org/example/siljeun/global/config/RedisConfig.java +++ b/src/main/java/org/example/siljeun/global/config/RedisConfig.java @@ -15,46 +15,58 @@ @Configuration public class RedisConfig { - @Value("${spring.data.redis.host}") - private String host; - - @Value("${spring.data.redis.port}") - private int port; - - private static final String REDISSON_PREFIX = "redis://"; - - /** - * Redisson 클라이언트 설정 - */ - @Bean - public RedissonClient redissonClient() { - Config config = new Config(); - config.useSingleServer() - .setAddress(REDISSON_PREFIX + host + ":" + port); - return Redisson.create(config); - } - - /** - * Long 타입 RedisTemplate (조회수 등 숫자 기반 저장용) - */ - @Bean - public RedisTemplate redisLongTemplate(RedisConnectionFactory connectionFactory) { - RedisTemplate redisTemplate = new RedisTemplate<>(); - redisTemplate.setConnectionFactory(connectionFactory); - redisTemplate.setKeySerializer(new StringRedisSerializer()); - redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Long.class)); - return redisTemplate; - } - - /** - * JSON 직렬화 RedisTemplate (객체 캐싱용) - */ - @Bean - public RedisTemplate redisJsonTemplate(RedisConnectionFactory connectionFactory) { - RedisTemplate template = new RedisTemplate<>(); - template.setConnectionFactory(connectionFactory); - template.setKeySerializer(new StringRedisSerializer()); - template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // JSON 직렬화 - return template; - } + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + private static final String REDISSON_PREFIX = "redis://"; + + /** + * Redisson 클라이언트 설정 + */ + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer() + .setAddress(REDISSON_PREFIX + host + ":" + port); + return Redisson.create(config); + } + + /** + * Long 타입 RedisTemplate (조회수 등 숫자 기반 저장용) + */ + @Bean + public RedisTemplate redisLongTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(connectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Long.class)); + return redisTemplate; + } + + /** + * JSON 직렬화 RedisTemplate (객체 캐싱용) + */ + @Bean + public RedisTemplate redisJsonTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // JSON 직렬화 + return template; + } + + /** + * String 타입 RedisTemplate + */ + @Bean + public RedisTemplate redisStringTemplate(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..e225c9d 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 @@ -17,6 +17,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.redis.core.RedisTemplate; @@ -58,6 +59,7 @@ class SeatScheduleInfoServiceTest { private ConcertRepository concertRepository; @Autowired + @Qualifier("redisLongTemplate") private RedisTemplate redisTemplate; private Seat seat; @@ -110,7 +112,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);