Skip to content

Commit 5e06237

Browse files
authored
feat: 스케줄러 도입 (#288)
* feat: 엔티티 세팅 * feat: 엔티티 인덱스 추가 * feat: 엔티티 만료 시간 추가 관련 메서드 수정 * feat: 메서드 일부 수정 * feat: 레포지토리에 변경 로직 추기 * feat: 스케줄러, 서비스 추가 * feat: 테스트 컨트롤러 추가 * feat: 테스트 코드 수정 * feat: 메서드로 코드 수정 * feat: 검증 코드 추가 * feat: 테스트 코드 추가 * feat: 동시성 테스트 추가 * feat: 동시성 테스트 수정
1 parent 689bf0b commit 5e06237

19 files changed

Lines changed: 512 additions & 207 deletions

src/main/java/com/back/b2st/domain/reservation/service/ReservationService.java

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import com.back.b2st.domain.reservation.entity.ReservationStatus;
1414
import com.back.b2st.domain.reservation.error.ReservationErrorCode;
1515
import com.back.b2st.domain.reservation.repository.ReservationRepository;
16-
import com.back.b2st.domain.scheduleseat.entity.ScheduleSeat;
1716
import com.back.b2st.domain.scheduleseat.repository.ScheduleSeatRepository;
1817
import com.back.b2st.domain.scheduleseat.service.ScheduleSeatStateService;
1918
import com.back.b2st.domain.scheduleseat.service.SeatHoldTokenService;
@@ -68,6 +67,8 @@ public void cancelReservation(Long reservationId, Long memberId) {
6867
reservation.getScheduleId(),
6968
reservation.getSeatId()
7069
);
70+
71+
seatHoldTokenService.remove(reservation.getScheduleId(), reservation.getSeatId());
7172
}
7273

7374
/** === 예매 확정 === */
@@ -91,6 +92,8 @@ public void completeReservation(Long reservationId) {
9192
reservation.getScheduleId(),
9293
reservation.getSeatId()
9394
);
95+
96+
seatHoldTokenService.remove(reservation.getScheduleId(), reservation.getSeatId());
9497
}
9598

9699
/** === 예매 만료 === */
@@ -105,9 +108,13 @@ public void expireReservation(Long reservationId) {
105108

106109
reservation.expire();
107110

108-
scheduleSeatRepository
109-
.findByScheduleIdAndSeatId(reservation.getScheduleId(), reservation.getSeatId())
110-
.ifPresent(ScheduleSeat::release);
111+
// 좌석 상태 복구 (HOLD → AVAILABLE)
112+
scheduleSeatStateService.changeToAvailable(
113+
reservation.getScheduleId(),
114+
reservation.getSeatId()
115+
);
116+
117+
seatHoldTokenService.remove(reservation.getScheduleId(), reservation.getSeatId());
111118
}
112119

113120
/** === 예매 단건 조회 === */
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.back.b2st.domain.scheduleseat.controller;
2+
3+
import org.springframework.web.bind.annotation.PathVariable;
4+
import org.springframework.web.bind.annotation.PostMapping;
5+
import org.springframework.web.bind.annotation.RequestMapping;
6+
import org.springframework.web.bind.annotation.RestController;
7+
8+
import com.back.b2st.domain.scheduleseat.service.ScheduleSeatStateService;
9+
import com.back.b2st.global.common.BaseResponse;
10+
11+
import lombok.RequiredArgsConstructor;
12+
13+
@RestController
14+
@RequiredArgsConstructor
15+
@RequestMapping("/api/test/schedules")
16+
public class ScheduleSeatTestController {
17+
18+
private final ScheduleSeatStateService scheduleSeatStateService;
19+
20+
/** === 만료된 HOLD 좌석 일괄 해제 (수동) === */
21+
@PostMapping("/expired/release")
22+
public BaseResponse<Integer> releaseExpiredHolds() {
23+
int updated = scheduleSeatStateService.releaseExpiredHolds();
24+
return BaseResponse.success(updated);
25+
}
26+
27+
/** === 특정 좌석을 강제로 AVAILABLE로 복구 === */
28+
@PostMapping("/{scheduleId}/seats/{seatId}/release")
29+
public BaseResponse<Void> forceReleaseSeat(
30+
@PathVariable Long scheduleId,
31+
@PathVariable Long seatId
32+
) {
33+
scheduleSeatStateService.changeToAvailable(scheduleId, seatId);
34+
return BaseResponse.success();
35+
}
36+
37+
}

src/main/java/com/back/b2st/domain/scheduleseat/entity/ScheduleSeat.java

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.back.b2st.domain.scheduleseat.entity;
22

3+
import java.time.LocalDateTime;
4+
35
import com.back.b2st.global.jpa.entity.BaseEntity;
46

57
import jakarta.persistence.Column;
@@ -25,7 +27,8 @@
2527
name = "schedule_seat",
2628
indexes = {
2729
@Index(name = "idx_schedule_seat_schedule_seat", columnList = "schedule_id, seat_id"),
28-
@Index(name = "idx_schedule_seat_schedule_status", columnList = "schedule_id, status")
30+
@Index(name = "idx_schedule_seat_schedule_status", columnList = "schedule_id, status"),
31+
@Index(name = "idx_schedule_seat_status_expired", columnList = "status, hold_expired_at")
2932
},
3033
uniqueConstraints = {
3134
@UniqueConstraint(name = "uk_schedule_seat_schedule_seat", columnNames = {"schedule_id", "seat_id"})
@@ -53,31 +56,38 @@ public class ScheduleSeat extends BaseEntity {
5356
@Column(name = "status", nullable = false, length = 20)
5457
private SeatStatus status;
5558

59+
@Column(name = "hold_expired_at")
60+
private LocalDateTime holdExpiredAt; // HOLD 만료 시각
61+
5662
@Builder
5763
public ScheduleSeat(Long scheduleId, Long seatId) {
5864
this.scheduleId = scheduleId;
5965
this.seatId = seatId;
6066
this.status = SeatStatus.AVAILABLE;
67+
this.holdExpiredAt = null;
6168
}
6269

63-
/* === 상태 전환 메서드 === */
70+
/** === 상태 전환 메서드 === */
6471

65-
/** HOLD 상태로 변경 */
66-
public void hold() {
72+
/* HOLD 상태로 변경 */
73+
public void hold(LocalDateTime expiredAt) {
6774
this.status = SeatStatus.HOLD;
75+
this.holdExpiredAt = expiredAt;
6876
}
6977

70-
/** SOLD(예매 확정) 상태로 변경 */
78+
/* SOLD(예매 확정) 상태로 변경 */
7179
public void sold() {
7280
this.status = SeatStatus.SOLD;
81+
this.holdExpiredAt = null;
7382
}
7483

75-
/** AVAILABLE 상태로 복구 (취소) */
84+
/* AVAILABLE 상태로 복구 (취소) */
7685
public void release() {
7786
this.status = SeatStatus.AVAILABLE;
87+
this.holdExpiredAt = null;
7888
}
7989

80-
/** HOLD 상태인지 확인 */
90+
/* HOLD 상태인지 확인 */
8191
public boolean isHold() {
8292
return this.status == SeatStatus.HOLD;
8393
}

src/main/java/com/back/b2st/domain/scheduleseat/repository/ScheduleSeatRepository.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package com.back.b2st.domain.scheduleseat.repository;
22

3+
import java.time.LocalDateTime;
34
import java.util.List;
45
import java.util.Optional;
56

67
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.Modifying;
9+
import org.springframework.data.jpa.repository.Query;
10+
import org.springframework.data.repository.query.Param;
711

812
import com.back.b2st.domain.scheduleseat.entity.ScheduleSeat;
913
import com.back.b2st.domain.scheduleseat.entity.SeatStatus;
@@ -18,4 +22,19 @@ public interface ScheduleSeatRepository extends JpaRepository<ScheduleSeat, Long
1822

1923
/* 특정 회차에서 특정 상태의 좌석 조회 (예: AVAILABLE 좌석만) */
2024
List<ScheduleSeat> findByScheduleIdAndStatus(Long scheduleId, SeatStatus status);
25+
26+
/* HOLD 만료 좌석을 AVAILABLE로 일괄 복구 */
27+
@Modifying(clearAutomatically = true, flushAutomatically = true)
28+
@Query("""
29+
update ScheduleSeat s
30+
set s.status = :available,
31+
s.holdExpiredAt = null
32+
where s.status = :hold
33+
and s.holdExpiredAt is not null
34+
and s.holdExpiredAt <= :now
35+
""")
36+
int releaseExpiredHolds(
37+
@Param("hold") SeatStatus hold,
38+
@Param("available") SeatStatus available,
39+
@Param("now") LocalDateTime now);
2140
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.back.b2st.domain.scheduleseat.scheduler;
2+
3+
import org.springframework.scheduling.annotation.Scheduled;
4+
import org.springframework.stereotype.Component;
5+
6+
import com.back.b2st.domain.scheduleseat.service.ScheduleSeatStateService;
7+
8+
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
11+
@Slf4j
12+
@Component
13+
@RequiredArgsConstructor
14+
public class ScheduleSeatScheduler {
15+
16+
private final ScheduleSeatStateService scheduleSeatStateService;
17+
18+
/** === HOLD 만료 좌석 자동 복구 스케줄러 === */
19+
@Scheduled(fixedDelayString = "${seat.hold.expire-release-interval-ms:5000}")
20+
public void releaseExpiredHolds() {
21+
int updated = scheduleSeatStateService.releaseExpiredHolds();
22+
if (updated > 0) {
23+
log.info("[HOLD 만료 복구] 복구된 좌석 수={}", updated);
24+
}
25+
}
26+
}

src/main/java/com/back/b2st/domain/scheduleseat/service/ScheduleSeatStateService.java

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.back.b2st.domain.scheduleseat.service;
22

3+
import java.time.LocalDateTime;
4+
35
import org.springframework.stereotype.Service;
46
import org.springframework.transaction.annotation.Transactional;
57

@@ -43,43 +45,66 @@ public void holdSeat(Long memberId, Long scheduleId, Long seatId) {
4345
}
4446
}
4547

48+
/** === 만료된 HOLD 좌석을 AVAILABLE로 일괄 복구 === */
49+
@Transactional
50+
public int releaseExpiredHolds() {
51+
LocalDateTime now = LocalDateTime.now();
52+
return scheduleSeatRepository.releaseExpiredHolds(SeatStatus.HOLD, SeatStatus.AVAILABLE, now);
53+
}
54+
4655
// === 상태 변경 AVAILABLE → HOLD === //
56+
@Transactional
4757
public void changeToHold(Long scheduleId, Long seatId) {
4858
ScheduleSeat seat = getScheduleSeat(scheduleId, seatId);
59+
4960
if (seat.getStatus() == SeatStatus.SOLD) {
5061
throw new BusinessException(ScheduleSeatErrorCode.SEAT_ALREADY_SOLD);
5162
}
5263
if (seat.getStatus() == SeatStatus.HOLD) {
5364
throw new BusinessException(ScheduleSeatErrorCode.SEAT_ALREADY_HOLD);
5465
}
55-
seat.hold();
66+
67+
LocalDateTime expiredAt = LocalDateTime.now().plus(SeatHoldTokenService.HOLD_TTL);
68+
69+
seat.hold(expiredAt);
5670
}
5771

5872
// === 상태 변경 HOLD → AVAILABLE === //
5973
@Transactional
6074
public void changeToAvailable(Long scheduleId, Long seatId) {
6175
ScheduleSeat seat = getScheduleSeat(scheduleId, seatId);
76+
77+
if (seat.getStatus() == SeatStatus.AVAILABLE) {
78+
return;
79+
}
80+
6281
if (seat.getStatus() != SeatStatus.HOLD) {
6382
return;
6483
}
84+
6585
seat.release();
6686
}
6787

6888
// === 상태 변경 HOLD → SOLD === //
6989
@Transactional
7090
public void changeToSold(Long scheduleId, Long seatId) {
7191
ScheduleSeat seat = getScheduleSeat(scheduleId, seatId);
92+
93+
if (seat.getStatus() == SeatStatus.SOLD) {
94+
return;
95+
}
96+
7297
if (seat.getStatus() != SeatStatus.HOLD) {
7398
throw new BusinessException(ScheduleSeatErrorCode.SEAT_NOT_HOLD);
7499
}
100+
75101
seat.sold();
76102
}
77103

78104
// === 좌석 조회 공통 로직 === //
79105
private ScheduleSeat getScheduleSeat(Long scheduleId, Long seatId) {
80-
ScheduleSeat seat = scheduleSeatRepository
106+
return scheduleSeatRepository
81107
.findByScheduleIdAndSeatId(scheduleId, seatId)
82108
.orElseThrow(() -> new BusinessException(ScheduleSeatErrorCode.SEAT_NOT_FOUND));
83-
return seat;
84109
}
85110
}

src/main/java/com/back/b2st/domain/scheduleseat/service/SeatHoldTokenService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public class SeatHoldTokenService {
1616

1717
private final StringRedisTemplate redisTemplate;
1818

19-
private static final Duration HOLD_TTL = Duration.ofMinutes(5);
19+
public static final Duration HOLD_TTL = Duration.ofMinutes(5);
2020

2121
/** === HOLD 소유권 저장 === */
2222
public void save(Long scheduleId, Long seatId, Long memberId) {

src/main/java/com/back/b2st/global/init/DataInitializer.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ record SectionConfig(Section section) {
346346
.orElseThrow(() -> new IllegalStateException("ScheduleSeat not found"));
347347

348348
// 좌석 상태 HOLD 처리 (결제 완료했지만 확정 전)
349-
paidScheduleSeat.hold();
349+
paidScheduleSeat.hold(LocalDateTime.now().plusMinutes(5));
350350

351351
// 예매 생성
352352
Reservation paidReservation = Reservation.builder()

src/test/java/com/back/b2st/domain/payment/controller/PaymentConfirmControllerTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ void confirm_success() throws Exception {
8484
.scheduleId(scheduleId)
8585
.seatId(seatId)
8686
.build();
87-
scheduleSeat.hold();
87+
scheduleSeat.hold(LocalDateTime.now().plusMinutes(5));
8888
scheduleSeatRepository.save(scheduleSeat);
8989

9090
Payment payment = Payment.builder()

src/test/java/com/back/b2st/domain/payment/service/PaymentConfirmServiceConcurrencyTest.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package com.back.b2st.domain.payment.service;
22

3+
import static org.assertj.core.api.Assertions.*;
4+
5+
import java.time.LocalDateTime;
36
import java.util.ArrayList;
47
import java.util.List;
58
import java.util.concurrent.Callable;
@@ -27,8 +30,6 @@
2730
import com.back.b2st.domain.scheduleseat.repository.ScheduleSeatRepository;
2831
import com.back.b2st.domain.ticket.repository.TicketRepository;
2932

30-
import static org.assertj.core.api.Assertions.*;
31-
3233
@SpringBootTest
3334
@ActiveProfiles("test")
3435
class PaymentConfirmServiceConcurrencyTest {
@@ -72,7 +73,7 @@ void confirm_concurrent_onlyOneApplied() throws Exception {
7273
.scheduleId(scheduleId)
7374
.seatId(seatId)
7475
.build();
75-
scheduleSeat.hold();
76+
scheduleSeat.hold(LocalDateTime.now().plusMinutes(5));
7677
scheduleSeatRepository.save(scheduleSeat);
7778

7879
Reservation reservation = Reservation.builder()

0 commit comments

Comments
 (0)