Skip to content

Commit 2059cff

Browse files
authored
feat : 공연 예매 페이지 조회 기능 추가 (동기화 기능 미구현) (#19)
* feat : 회차별 좌석 정보 Redis 적재 스케줄링 기능 추가 * feat : Redis 캐시 기반 좌석 조회 (DB와의 동기화 구현 필요)
1 parent 8c8e5e4 commit 2059cff

7 files changed

Lines changed: 193 additions & 54 deletions

File tree

src/main/java/org/example/siljeun/domain/schedule/controller/SeatScheduleInfoController.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@
22

33
import lombok.RequiredArgsConstructor;
44
import org.example.siljeun.domain.schedule.service.SeatScheduleInfoService;
5+
import org.example.siljeun.domain.seat.dto.response.SeatScheduleInfoResponse;
6+
import org.example.siljeun.global.security.CustomUserDetails;
57
import org.springframework.http.ResponseEntity;
8+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
69
import org.springframework.stereotype.Controller;
10+
import org.springframework.web.bind.annotation.GetMapping;
711
import org.springframework.web.bind.annotation.PathVariable;
812
import org.springframework.web.bind.annotation.PostMapping;
913
import org.springframework.web.bind.annotation.RequestMapping;
1014

15+
import java.util.List;
16+
import java.util.Map;
17+
1118
@Controller
1219
@RequiredArgsConstructor
1320
public class SeatScheduleInfoController {
@@ -16,10 +23,17 @@ public class SeatScheduleInfoController {
1623

1724
@PostMapping("/seat-schedule-info/{seatScheduleInfoId}")
1825
public ResponseEntity<String> selectSeat(
19-
@PathVariable Long seatScheduleInfoId
26+
@PathVariable Long seatScheduleInfoId,
27+
@AuthenticationPrincipal CustomUserDetails userDetails
2028
){
21-
seatScheduleInfoService.selectSeat(1L, seatScheduleInfoId);
22-
29+
seatScheduleInfoService.selectSeat(userDetails.getUserId(), seatScheduleInfoId);
2330
return ResponseEntity.ok("좌석이 선택되었습니다.");
2431
}
32+
33+
@GetMapping("/schedule/{scheduleId}/seat-schedule-info")
34+
public ResponseEntity<Map<String, String>> getSeatScheduleInfos(
35+
@PathVariable Long scheduleId
36+
){
37+
return ResponseEntity.ok(seatScheduleInfoService.getSeatStatusMap(scheduleId));
38+
}
2539
}

src/main/java/org/example/siljeun/domain/schedule/repository/ScheduleRepository.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package org.example.siljeun.domain.schedule.repository;
22

3+
import java.time.LocalDateTime;
34
import java.util.List;
45
import java.util.Optional;
56
import org.example.siljeun.domain.schedule.entity.Schedule;
@@ -9,5 +10,7 @@ public interface ScheduleRepository extends JpaRepository<Schedule, Long>, Sched
910

1011
List<Schedule> findByConcertId(Long concertId);
1112

13+
List<Schedule> findAllByTicketingStartTimeBetween(LocalDateTime ticketingStartTimeAfter, LocalDateTime ticketingStartTimeBefore);
14+
1215
Optional<Schedule> findById(Long id);
1316
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package org.example.siljeun.domain.schedule.repository;
22

3+
import org.example.siljeun.domain.schedule.entity.Schedule;
34
import org.example.siljeun.domain.seat.entity.SeatScheduleInfo;
45
import org.springframework.data.jpa.repository.JpaRepository;
56

7+
import java.util.List;
8+
69
public interface SeatScheduleInfoRepository extends JpaRepository<SeatScheduleInfo, Long> {
710

11+
List<SeatScheduleInfo> findAllBySchedule(Schedule schedule);
812
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package org.example.siljeun.domain.schedule.scheduler;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.example.siljeun.domain.schedule.entity.Schedule;
6+
import org.example.siljeun.domain.schedule.repository.ScheduleRepository;
7+
import org.example.siljeun.domain.schedule.repository.SeatScheduleInfoRepository;
8+
import org.example.siljeun.domain.seat.entity.SeatScheduleInfo;
9+
import org.springframework.beans.factory.annotation.Qualifier;
10+
import org.springframework.data.redis.core.RedisTemplate;
11+
import org.springframework.scheduling.annotation.Scheduled;
12+
import org.springframework.stereotype.Component;
13+
14+
import java.time.LocalDateTime;
15+
import java.util.List;
16+
import java.util.Map;
17+
import java.util.stream.Collectors;
18+
19+
@Slf4j
20+
@Component
21+
public class TicketingRedisScheduler {
22+
private final ScheduleRepository scheduleRepository;
23+
private final SeatScheduleInfoRepository seatScheduleInfoRepository;
24+
private final RedisTemplate<String, String> redisStatusTemplate;
25+
26+
public TicketingRedisScheduler(
27+
ScheduleRepository scheduleRepository,
28+
SeatScheduleInfoRepository seatScheduleInfoRepository,
29+
@Qualifier("redisStringTemplate") RedisTemplate<String, String> redisStatusTemplate
30+
){
31+
this.scheduleRepository = scheduleRepository;
32+
this.seatScheduleInfoRepository = seatScheduleInfoRepository;
33+
this.redisStatusTemplate = redisStatusTemplate;
34+
}
35+
36+
@Scheduled(fixedRate = 60_000)
37+
public void loadSeatStatusToRedis() {
38+
LocalDateTime now = LocalDateTime.now();
39+
LocalDateTime fiveMinutesLater = now.plusMinutes(5); //티켓팅 시작 시간이 임박한 회차에 대해 미리 Redis에 정보 적재
40+
41+
List<Schedule> openedSchedules = scheduleRepository.findAllByTicketingStartTimeBetween(now, fiveMinutesLater);
42+
43+
for (Schedule schedule : openedSchedules) {
44+
List<SeatScheduleInfo> seatScheduleInfos = seatScheduleInfoRepository.findAllBySchedule(schedule);
45+
46+
for(SeatScheduleInfo seatScheduleInfo : seatScheduleInfos){
47+
String key = "seatStatus:" + seatScheduleInfo.getId().toString();
48+
String value = seatScheduleInfo.getStatus().name();
49+
redisStatusTemplate.opsForValue().set(key, value);
50+
}
51+
}
52+
}
53+
}
Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,91 @@
11
package org.example.siljeun.domain.schedule.service;
22

3-
import lombok.RequiredArgsConstructor;
3+
import jakarta.persistence.EntityNotFoundException;
44
import lombok.extern.slf4j.Slf4j;
5+
import org.example.siljeun.domain.schedule.entity.Schedule;
6+
import org.example.siljeun.domain.schedule.repository.ScheduleRepository;
57
import org.example.siljeun.domain.schedule.repository.SeatScheduleInfoRepository;
68
import org.example.siljeun.domain.seat.entity.SeatScheduleInfo;
79
import org.example.siljeun.domain.seat.enums.SeatStatus;
810
import org.example.siljeun.global.lock.DistributedLock;
11+
import org.springframework.beans.factory.annotation.Qualifier;
912
import org.springframework.data.redis.core.RedisTemplate;
1013
import org.springframework.http.HttpStatus;
1114
import org.springframework.stereotype.Service;
1215
import org.springframework.web.server.ResponseStatusException;
1316

1417
import java.time.Duration;
18+
import java.util.HashMap;
19+
import java.util.List;
20+
import java.util.Map;
21+
1522
@Slf4j
1623
@Service
17-
@RequiredArgsConstructor
1824
public class SeatScheduleInfoService {
1925
private final SeatScheduleInfoRepository seatScheduleInfoRepository;
20-
private final RedisTemplate<String, Long> redisTemplate;
26+
private final ScheduleRepository scheduleRepository;
27+
private final RedisTemplate<String, Long> redisSeatUserTemplate;
28+
private final RedisTemplate<String, String> redisStatusTemplate;
29+
30+
public SeatScheduleInfoService(
31+
SeatScheduleInfoRepository seatScheduleInfoRepository,
32+
ScheduleRepository scheduleRepository,
33+
@Qualifier("redisLongTemplate") RedisTemplate<String, Long> redisSeatUserTemplate,
34+
@Qualifier("redisStringTemplate") RedisTemplate<String, String> redisStatusTemplate
35+
){
36+
this.seatScheduleInfoRepository = seatScheduleInfoRepository;
37+
this.scheduleRepository = scheduleRepository;
38+
this.redisSeatUserTemplate = redisSeatUserTemplate;
39+
this.redisStatusTemplate = redisStatusTemplate;
40+
}
2141

2242
@DistributedLock(key = "'seat:' + #seatScheduleInfoId")
2343
public void selectSeat(Long userId, Long seatScheduleInfoId) {
2444

25-
SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 회차의 좌석 정보를 찾을 수 없습니다."));
45+
SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId).
46+
orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다."));
2647

2748
if (!seatScheduleInfo.isAvailable()) {
2849
//log.info("이미 선점된 좌석입니다.");
2950
throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 선점된 좌석입니다.");
3051
}
3152

32-
seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.HOLD);
53+
seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.SELECTED);
3354
seatScheduleInfoRepository.save(seatScheduleInfo);
3455

3556
String redisKey = "seat:" + seatScheduleInfoId;
36-
redisTemplate.opsForValue().set(redisKey, userId, Duration.ofMinutes(5));
37-
Object redisValue = redisTemplate.opsForValue().get(redisKey);
38-
//log.info("좌석 선택 성공 [redis 저장 : {} = {}]", redisKey, redisValue);
57+
redisSeatUserTemplate.opsForValue().set(redisKey, userId, Duration.ofMinutes(5));
58+
59+
String redisStatusKey = "seatStatus:" + seatScheduleInfoId;
60+
redisStatusTemplate.opsForValue().set(redisStatusKey, seatScheduleInfo.getStatus().name(), Duration.ofMinutes(5));
61+
}
62+
63+
public Map<String, String> getSeatStatusMap(Long scheduleId) {
64+
65+
Schedule schedule = scheduleRepository.findById(scheduleId)
66+
.orElseThrow(() -> new EntityNotFoundException("해당 회차가 존재하지 않습니다."));
67+
68+
List<SeatScheduleInfo> seatScheduleInfos =
69+
seatScheduleInfoRepository.findAllBySchedule(schedule);
70+
71+
Map<String, String> result = new HashMap<>();
72+
73+
for (SeatScheduleInfo info : seatScheduleInfos) {
74+
String redisKey = "seatStatus:" + info.getId();
75+
String redisStatus = redisStatusTemplate.opsForValue().get(redisKey);
76+
77+
String status;
78+
if (redisStatus != null) {
79+
status = redisStatus;
80+
} else if (info.getStatus() == SeatStatus.SELECTED) { //TTL에 의해서 Redis에서는 만료되었으나 DB에 Selected로 저장된 경우
81+
status = SeatStatus.AVAILABLE.name();
82+
} else {
83+
status = info.getStatus().name();
84+
}
85+
86+
result.put("seatScheduleInfo-" + info.getId().toString(), status);
87+
}
88+
89+
return result;
3990
}
4091
}

src/main/java/org/example/siljeun/global/config/RedisConfig.java

Lines changed: 54 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -15,46 +15,58 @@
1515
@Configuration
1616
public class RedisConfig {
1717

18-
@Value("${spring.data.redis.host}")
19-
private String host;
20-
21-
@Value("${spring.data.redis.port}")
22-
private int port;
23-
24-
private static final String REDISSON_PREFIX = "redis://";
25-
26-
/**
27-
* Redisson 클라이언트 설정
28-
*/
29-
@Bean
30-
public RedissonClient redissonClient() {
31-
Config config = new Config();
32-
config.useSingleServer()
33-
.setAddress(REDISSON_PREFIX + host + ":" + port);
34-
return Redisson.create(config);
35-
}
36-
37-
/**
38-
* Long 타입 RedisTemplate (조회수 등 숫자 기반 저장용)
39-
*/
40-
@Bean
41-
public RedisTemplate<String, Long> redisLongTemplate(RedisConnectionFactory connectionFactory) {
42-
RedisTemplate<String, Long> redisTemplate = new RedisTemplate<>();
43-
redisTemplate.setConnectionFactory(connectionFactory);
44-
redisTemplate.setKeySerializer(new StringRedisSerializer());
45-
redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Long.class));
46-
return redisTemplate;
47-
}
48-
49-
/**
50-
* JSON 직렬화 RedisTemplate (객체 캐싱용)
51-
*/
52-
@Bean
53-
public RedisTemplate<String, Object> redisJsonTemplate(RedisConnectionFactory connectionFactory) {
54-
RedisTemplate<String, Object> template = new RedisTemplate<>();
55-
template.setConnectionFactory(connectionFactory);
56-
template.setKeySerializer(new StringRedisSerializer());
57-
template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // JSON 직렬화
58-
return template;
59-
}
18+
@Value("${spring.data.redis.host}")
19+
private String host;
20+
21+
@Value("${spring.data.redis.port}")
22+
private int port;
23+
24+
private static final String REDISSON_PREFIX = "redis://";
25+
26+
/**
27+
* Redisson 클라이언트 설정
28+
*/
29+
@Bean
30+
public RedissonClient redissonClient() {
31+
Config config = new Config();
32+
config.useSingleServer()
33+
.setAddress(REDISSON_PREFIX + host + ":" + port);
34+
return Redisson.create(config);
35+
}
36+
37+
/**
38+
* Long 타입 RedisTemplate (조회수 등 숫자 기반 저장용)
39+
*/
40+
@Bean
41+
public RedisTemplate<String, Long> redisLongTemplate(RedisConnectionFactory connectionFactory) {
42+
RedisTemplate<String, Long> redisTemplate = new RedisTemplate<>();
43+
redisTemplate.setConnectionFactory(connectionFactory);
44+
redisTemplate.setKeySerializer(new StringRedisSerializer());
45+
redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Long.class));
46+
return redisTemplate;
47+
}
48+
49+
/**
50+
* JSON 직렬화 RedisTemplate (객체 캐싱용)
51+
*/
52+
@Bean
53+
public RedisTemplate<String, Object> redisJsonTemplate(RedisConnectionFactory connectionFactory) {
54+
RedisTemplate<String, Object> template = new RedisTemplate<>();
55+
template.setConnectionFactory(connectionFactory);
56+
template.setKeySerializer(new StringRedisSerializer());
57+
template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // JSON 직렬화
58+
return template;
59+
}
60+
61+
/**
62+
* String 타입 RedisTemplate
63+
*/
64+
@Bean
65+
public RedisTemplate<String, String> redisStringTemplate(RedisConnectionFactory connectionFactory) {
66+
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
67+
redisTemplate.setConnectionFactory(connectionFactory);
68+
redisTemplate.setKeySerializer(new StringRedisSerializer());
69+
redisTemplate.setValueSerializer(new StringRedisSerializer());
70+
return redisTemplate;
71+
}
6072
}

src/test/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoServiceTest.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import org.junit.jupiter.api.Test;
1818
import org.junit.jupiter.api.TestInstance;
1919
import org.springframework.beans.factory.annotation.Autowired;
20+
import org.springframework.beans.factory.annotation.Qualifier;
2021
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
2122
import org.springframework.boot.test.context.SpringBootTest;
2223
import org.springframework.data.redis.core.RedisTemplate;
@@ -58,6 +59,7 @@ class SeatScheduleInfoServiceTest {
5859
private ConcertRepository concertRepository;
5960

6061
@Autowired
62+
@Qualifier("redisLongTemplate")
6163
private RedisTemplate<String, Long> redisTemplate;
6264

6365
private Seat seat;
@@ -110,7 +112,7 @@ void sameSeatConcurrentAccessTest() throws InterruptedException {
110112
assertEquals(totalThreads - 1, conflictCount);
111113

112114
SeatScheduleInfo updated = seatScheduleInfoRepository.findById(seatScheduleInfoId).orElseThrow();
113-
assertEquals(SeatStatus.HOLD, updated.getStatus());
115+
assertEquals(SeatStatus.SELECTED, updated.getStatus());
114116

115117
Long storedUserId = redisTemplate.opsForValue().get("seat:" + seatScheduleInfoId);
116118
assertNotNull(storedUserId);

0 commit comments

Comments
 (0)