Skip to content

Commit 9410960

Browse files
authored
[fix] draft Ticket 만료 스케줄러 오작동에 대한 service로직 보충 - null체크, transactional 추가
* fix: draft Ticket 만료 스케줄러 오작동에 대한 service로직 보충 - null체크, transactional 어노테이션 추가 * feat: draft ticket 만료 스케줄러 성공/실패 카운팅 로그 추가
1 parent 0d9181b commit 9410960

4 files changed

Lines changed: 309 additions & 2 deletions

File tree

backend/src/main/java/com/back/api/ticket/scheduler/DraftTicketExpirationScheduler.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,23 @@ public void expireDraftTickets() {
3030
List<Ticket> expiredTickets =
3131
ticketRepository.findExpiredDraftTickets(TicketStatus.DRAFT, expiredBefore);
3232

33+
int totalCount = expiredTickets.size();
34+
int successCount = 0;
35+
int failCount = 0;
36+
37+
log.info("Draft 티켓 만료 스케줄러 시작 - 대상 티켓 수: {}", totalCount);
38+
3339
for (Ticket ticket : expiredTickets) {
3440
try {
3541
ticketService.failPayment(ticket.getId());
36-
log.info("만료된 Draft Ticket 처리 완료: ticketId={}", ticket.getId());
42+
successCount++;
43+
log.debug("만료된 Draft Ticket 처리 완료: ticketId={}", ticket.getId());
3744
} catch (Exception ex) {
45+
failCount++;
3846
log.error("Draft 티켓 만료 처리 실패 - ticketId={}", ticket.getId(), ex);
3947
}
4048
}
49+
50+
log.info("Draft 티켓 만료 스케줄러 완료 - 총: {}, 성공: {}, 실패: {}", totalCount, successCount, failCount);
4151
}
4252
}

backend/src/main/java/com/back/api/ticket/service/TicketService.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ public Ticket getDraftTicket(Long eventId, Long seatId, Long userId) {
124124
/**
125125
* 결제 완료 → Ticket 확정 발급
126126
*/
127+
@Transactional
127128
public Ticket confirmPayment(Long ticketId, Long userId) {
128129

129130
Ticket ticket = ticketRepository.findById(ticketId)
@@ -145,6 +146,7 @@ public Ticket confirmPayment(Long ticketId, Long userId) {
145146
/**
146147
* 결제 실패 → DRAFT 티켓 폐기 + 좌석 AVAILABLE 복구
147148
*/
149+
@Transactional
148150
public void failPayment(Long ticketId) {
149151

150152
Ticket ticket = ticketRepository.findById(ticketId)
@@ -154,7 +156,9 @@ public void failPayment(Long ticketId) {
154156
ticket.fail();
155157

156158
// 좌석 해제
157-
seatService.markSeatAsAvailable(ticket.getSeat());
159+
if (ticket.getSeat() != null) {
160+
seatService.markSeatAsAvailable(ticket.getSeat());
161+
}
158162
}
159163

160164
/**
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
package com.back.api.ticket.scheduler;
2+
3+
import static org.assertj.core.api.Assertions.*;
4+
5+
import java.lang.reflect.Field;
6+
import java.time.LocalDateTime;
7+
8+
import org.junit.jupiter.api.BeforeEach;
9+
import org.junit.jupiter.api.DisplayName;
10+
import org.junit.jupiter.api.Test;
11+
import org.springframework.beans.factory.annotation.Autowired;
12+
import org.springframework.boot.test.context.SpringBootTest;
13+
import org.springframework.test.context.ActiveProfiles;
14+
import org.springframework.transaction.annotation.Transactional;
15+
16+
import com.back.domain.event.entity.Event;
17+
import com.back.domain.seat.entity.Seat;
18+
import com.back.domain.seat.entity.SeatGrade;
19+
import com.back.domain.seat.entity.SeatStatus;
20+
import com.back.domain.seat.repository.SeatRepository;
21+
import com.back.domain.ticket.entity.Ticket;
22+
import com.back.domain.ticket.entity.TicketStatus;
23+
import com.back.domain.ticket.repository.TicketRepository;
24+
import com.back.domain.user.entity.User;
25+
import com.back.domain.user.entity.UserRole;
26+
import com.back.support.helper.EventHelper;
27+
import com.back.support.helper.SeatHelper;
28+
import com.back.support.helper.TicketHelper;
29+
import com.back.support.helper.UserHelper;
30+
31+
@SpringBootTest
32+
@ActiveProfiles("test")
33+
@Transactional
34+
@DisplayName("DraftTicketExpirationScheduler 통합 테스트")
35+
class DraftTicketExpirationSchedulerTest {
36+
37+
@Autowired
38+
private DraftTicketExpirationScheduler scheduler;
39+
40+
@Autowired
41+
private TicketRepository ticketRepository;
42+
43+
@Autowired
44+
private SeatRepository seatRepository;
45+
46+
@Autowired
47+
private UserHelper userHelper;
48+
49+
@Autowired
50+
private EventHelper eventHelper;
51+
52+
@Autowired
53+
private SeatHelper seatHelper;
54+
55+
@Autowired
56+
private TicketHelper ticketHelper;
57+
58+
private User user;
59+
private Event event;
60+
private Seat seat;
61+
62+
@BeforeEach
63+
void setUp() {
64+
user = userHelper.createUser(UserRole.NORMAL).user();
65+
event = eventHelper.createEvent();
66+
seat = seatHelper.createSeat(event, "A1", SeatGrade.VIP);
67+
}
68+
69+
@Test
70+
@DisplayName("좌석이 할당된 만료된 Draft 티켓을 정상적으로 처리한다")
71+
void expireDraftTicket_withSeat_success() throws Exception {
72+
// given: 15분 이상 경과한 Draft 티켓 생성 (좌석 할당됨)
73+
Ticket ticket = ticketHelper.createDraftTicket(user, seat, event);
74+
setCreatedAt(ticket, LocalDateTime.now().minusMinutes(20));
75+
76+
// 좌석을 RESERVED 상태로 변경
77+
seat.markAsReserved();
78+
seatRepository.save(seat);
79+
80+
// when: 스케줄러 실행
81+
scheduler.expireDraftTickets();
82+
83+
// then: 티켓이 FAILED 상태로 변경됨
84+
Ticket expiredTicket = ticketRepository.findById(ticket.getId()).orElseThrow();
85+
assertThat(expiredTicket.getTicketStatus()).isEqualTo(TicketStatus.FAILED);
86+
87+
// then: 좌석이 AVAILABLE 상태로 변경됨
88+
Seat releasedSeat = seatRepository.findById(seat.getId()).orElseThrow();
89+
assertThat(releasedSeat.getSeatStatus()).isEqualTo(SeatStatus.AVAILABLE);
90+
}
91+
92+
@Test
93+
@DisplayName("좌석이 없는 만료된 Draft 티켓 처리 시 NPE 발생으로 FAILED 변경 실패 (현재 버그)")
94+
void expireDraftTicket_withoutSeat_npeOccurs() throws Exception {
95+
// given: 15분 이상 경과한 Draft 티켓 생성 (좌석 없음)
96+
Ticket ticket = Ticket.builder()
97+
.owner(user)
98+
.event(event)
99+
.seat(null) // 좌석 없음
100+
.ticketStatus(TicketStatus.DRAFT)
101+
.build();
102+
ticketRepository.save(ticket);
103+
setCreatedAt(ticket, LocalDateTime.now().minusMinutes(20));
104+
105+
// when: 스케줄러 실행 (NPE가 발생하지만 try-catch로 잡힘)
106+
scheduler.expireDraftTickets();
107+
108+
// then: NPE 발생으로 티켓 상태가 DRAFT 그대로 유지됨 (버그!)
109+
Ticket unchangedTicket = ticketRepository.findById(ticket.getId()).orElseThrow();
110+
assertThat(unchangedTicket.getTicketStatus())
111+
.as("NPE 발생으로 인해 DRAFT 상태 그대로 유지됨 (버그)")
112+
.isEqualTo(TicketStatus.FAILED); // 버그: FAILED가 되어야 하는데 DRAFT 그대로
113+
}
114+
115+
@Test
116+
@DisplayName("만료되지 않은 Draft 티켓은 처리하지 않는다")
117+
void expireDraftTicket_notExpired_ignored() {
118+
// given: 최근 생성된 Draft 티켓 (만료되지 않음)
119+
Ticket ticket = ticketHelper.createDraftTicket(user, seat, event);
120+
121+
// when: 스케줄러 실행
122+
scheduler.expireDraftTickets();
123+
124+
// then: 티켓 상태가 변경되지 않음
125+
Ticket notExpiredTicket = ticketRepository.findById(ticket.getId()).orElseThrow();
126+
assertThat(notExpiredTicket.getTicketStatus()).isEqualTo(TicketStatus.DRAFT);
127+
}
128+
129+
@Test
130+
@DisplayName("PAID 상태의 티켓은 처리하지 않는다")
131+
void expireDraftTicket_paidTicket_ignored() throws Exception {
132+
// given: 만료 시간이 지난 PAID 티켓
133+
Ticket ticket = ticketHelper.createPaidTicket(user, seat, event);
134+
setCreatedAt(ticket, LocalDateTime.now().minusMinutes(20));
135+
136+
// when: 스케줄러 실행
137+
scheduler.expireDraftTickets();
138+
139+
// then: 티켓 상태가 변경되지 않음 (PAID 유지)
140+
Ticket unchangedTicket = ticketRepository.findById(ticket.getId()).orElseThrow();
141+
assertThat(unchangedTicket.getTicketStatus()).isEqualTo(TicketStatus.PAID);
142+
}
143+
144+
@Test
145+
@DisplayName("여러 개의 만료된 Draft 티켓을 배치로 처리한다")
146+
void expireDraftTicket_multipleTickets_success() throws Exception {
147+
// given: 3개의 만료된 Draft 티켓 생성
148+
Seat seat2 = seatHelper.createSeat(event, "A2", SeatGrade.VIP);
149+
Seat seat3 = seatHelper.createSeat(event, "A3", SeatGrade.VIP);
150+
151+
Ticket ticket1 = ticketHelper.createDraftTicket(user, seat, event);
152+
Ticket ticket2 = ticketHelper.createDraftTicket(user, seat2, event);
153+
Ticket ticket3 = ticketHelper.createDraftTicket(user, seat3, event);
154+
155+
setCreatedAt(ticket1, LocalDateTime.now().minusMinutes(20));
156+
setCreatedAt(ticket2, LocalDateTime.now().minusMinutes(20));
157+
setCreatedAt(ticket3, LocalDateTime.now().minusMinutes(20));
158+
159+
// 좌석들을 RESERVED 상태로 변경
160+
seat.markAsReserved();
161+
seat2.markAsReserved();
162+
seat3.markAsReserved();
163+
seatRepository.save(seat);
164+
seatRepository.save(seat2);
165+
seatRepository.save(seat3);
166+
167+
// when: 스케줄러 실행
168+
scheduler.expireDraftTickets();
169+
170+
// then: 모든 티켓이 FAILED 상태로 변경됨
171+
assertThat(ticketRepository.findById(ticket1.getId()).orElseThrow().getTicketStatus())
172+
.isEqualTo(TicketStatus.FAILED);
173+
assertThat(ticketRepository.findById(ticket2.getId()).orElseThrow().getTicketStatus())
174+
.isEqualTo(TicketStatus.FAILED);
175+
assertThat(ticketRepository.findById(ticket3.getId()).orElseThrow().getTicketStatus())
176+
.isEqualTo(TicketStatus.FAILED);
177+
178+
// then: 모든 좌석이 AVAILABLE 상태로 변경됨
179+
assertThat(seatRepository.findById(seat.getId()).orElseThrow().getSeatStatus())
180+
.isEqualTo(SeatStatus.AVAILABLE);
181+
assertThat(seatRepository.findById(seat2.getId()).orElseThrow().getSeatStatus())
182+
.isEqualTo(SeatStatus.AVAILABLE);
183+
assertThat(seatRepository.findById(seat3.getId()).orElseThrow().getSeatStatus())
184+
.isEqualTo(SeatStatus.AVAILABLE);
185+
}
186+
187+
@Test
188+
@DisplayName("좌석이 없는 티켓과 있는 티켓이 섞여있어도 정상 처리한다")
189+
void expireDraftTicket_mixedTickets_success() throws Exception {
190+
// given: 좌석 있는 티켓과 없는 티켓 혼합
191+
Ticket ticketWithSeat = ticketHelper.createDraftTicket(user, seat, event);
192+
Ticket ticketWithoutSeat = Ticket.builder()
193+
.owner(user)
194+
.event(event)
195+
.seat(null)
196+
.ticketStatus(TicketStatus.DRAFT)
197+
.build();
198+
ticketRepository.save(ticketWithoutSeat);
199+
200+
setCreatedAt(ticketWithSeat, LocalDateTime.now().minusMinutes(20));
201+
setCreatedAt(ticketWithoutSeat, LocalDateTime.now().minusMinutes(20));
202+
203+
seat.markAsReserved();
204+
seatRepository.save(seat);
205+
206+
// when: 스케줄러 실행
207+
scheduler.expireDraftTickets();
208+
209+
// then: 두 티켓 모두 FAILED 상태로 변경됨
210+
assertThat(ticketRepository.findById(ticketWithSeat.getId()).orElseThrow().getTicketStatus())
211+
.isEqualTo(TicketStatus.FAILED);
212+
assertThat(ticketRepository.findById(ticketWithoutSeat.getId()).orElseThrow().getTicketStatus())
213+
.isEqualTo(TicketStatus.FAILED);
214+
215+
// then: 좌석 있던 티켓의 좌석만 AVAILABLE로 변경됨
216+
assertThat(seatRepository.findById(seat.getId()).orElseThrow().getSeatStatus())
217+
.isEqualTo(SeatStatus.AVAILABLE);
218+
}
219+
220+
// ========== Helper Methods ==========
221+
222+
/**
223+
* Reflection을 사용하여 createAt 필드를 수정
224+
*/
225+
private void setCreatedAt(Ticket ticket, LocalDateTime time) throws Exception {
226+
Field createAtField = ticket.getClass().getSuperclass().getDeclaredField("createAt");
227+
createAtField.setAccessible(true);
228+
createAtField.set(ticket, time);
229+
ticketRepository.saveAndFlush(ticket);
230+
}
231+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.back.api.ticket.service;
2+
3+
import static org.assertj.core.api.Assertions.*;
4+
5+
import org.junit.jupiter.api.DisplayName;
6+
import org.junit.jupiter.api.Test;
7+
import org.springframework.beans.factory.annotation.Autowired;
8+
import org.springframework.boot.test.context.SpringBootTest;
9+
import org.springframework.test.context.ActiveProfiles;
10+
11+
import com.back.domain.event.entity.Event;
12+
import com.back.domain.ticket.entity.Ticket;
13+
import com.back.domain.ticket.entity.TicketStatus;
14+
import com.back.domain.ticket.repository.TicketRepository;
15+
import com.back.domain.user.entity.User;
16+
import com.back.domain.user.entity.UserRole;
17+
import com.back.support.helper.EventHelper;
18+
import com.back.support.helper.UserHelper;
19+
20+
@SpringBootTest
21+
@ActiveProfiles("test")
22+
// @Transactional을 제거하여 프로덕션 환경 시뮬레이션
23+
@DisplayName("TicketService seat=null NPE 버그 검증")
24+
class TicketServiceBugTest {
25+
26+
@Autowired
27+
private TicketService ticketService;
28+
29+
@Autowired
30+
private TicketRepository ticketRepository;
31+
32+
@Autowired
33+
private UserHelper userHelper;
34+
35+
@Autowired
36+
private EventHelper eventHelper;
37+
38+
@Test
39+
@DisplayName("seat=null인 티켓에 failPayment() 호출 시 정상적으로 FAILED로 변경")
40+
void failPayment_withNullSeat_success() {
41+
// given: seat=null인 Draft 티켓 생성
42+
User user = userHelper.createUser(UserRole.NORMAL).user();
43+
Event event = eventHelper.createEvent();
44+
45+
Ticket ticket = Ticket.builder()
46+
.owner(user)
47+
.event(event)
48+
.seat(null) // seat이 null
49+
.ticketStatus(TicketStatus.DRAFT)
50+
.build();
51+
Ticket saved = ticketRepository.save(ticket);
52+
53+
// when: failPayment() 호출
54+
ticketService.failPayment(saved.getId());
55+
56+
// then: 티켓이 FAILED 상태로 변경됨
57+
Ticket result = ticketRepository.findById(saved.getId()).orElseThrow();
58+
assertThat(result.getTicketStatus())
59+
.as("seat=null이어도 안전하게 FAILED로 변경됨")
60+
.isEqualTo(TicketStatus.FAILED);
61+
}
62+
}

0 commit comments

Comments
 (0)