From fb4dc8495d89b36826e96b49d3936b2dfe711ea3 Mon Sep 17 00:00:00 2001 From: johe00123 Date: Wed, 13 May 2026 11:52:17 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EC=98=88=EC=95=BD=20=EC=8A=AC?= =?UTF-8?q?=EB=A1=AF=20=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0=20(=EB=B3=91=ED=95=A9)?= =?UTF-8?q?=20-=20=EB=A7=A4=EC=9D=BC=20=EC=83=88=EB=B2=BD=204=EC=8B=9C=20"?= =?UTF-8?q?=EC=98=A4=EB=8A=98=20+=2030=EC=9D=BC"=20=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=EC=9D=98=20=EC=8A=AC=EB=A1=AF=EC=9D=84=20=ED=99=9C=EC=84=B1=20?= =?UTF-8?q?=EB=A7=A4=EC=9E=A5=EC=97=90=20=EC=83=9D=EC=84=B1=20-=20?= =?UTF-8?q?=EC=8A=AC=EB=9D=BC=EC=9D=B4=EB=94=A9=20=EC=9C=88=EB=8F=84?= =?UTF-8?q?=EC=9A=B0=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=ED=95=AD?= =?UTF-8?q?=EC=83=81=2030=EC=9D=BC=EC=B9=98=20=EB=AF=B8=EB=9E=98=20?= =?UTF-8?q?=EC=8A=AC=EB=A1=AF=20=EC=9C=A0=EC=A7=80=20-=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=20=EC=8A=AC=EB=A1=AF=EC=9D=B4=20=EC=9E=88=EB=8A=94=20?= =?UTF-8?q?=EB=A7=A4=EC=9E=A5/=EB=82=A0=EC=A7=9C=EB=8A=94=20=EC=8A=A4?= =?UTF-8?q?=ED=82=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/StoreRemainRepository.java | 2 + .../StoreRemainGenerationScheduler.java | 35 ++++++++++ .../remain/service/StoreRemainService.java | 69 ++++++++++++++----- 3 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/catchtable/remain/scheduler/StoreRemainGenerationScheduler.java diff --git a/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java b/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java index 9e95850..fe63b8a 100644 --- a/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java +++ b/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java @@ -31,4 +31,6 @@ Optional findByStoreNameAndDateTime( @Param("date") LocalDate date, @Param("time") LocalTime time ); + + boolean existsByStoreIdAndRemainDate(Long storeId, LocalDate remainDate); } diff --git a/src/main/java/com/catchtable/remain/scheduler/StoreRemainGenerationScheduler.java b/src/main/java/com/catchtable/remain/scheduler/StoreRemainGenerationScheduler.java new file mode 100644 index 0000000..bbc68cb --- /dev/null +++ b/src/main/java/com/catchtable/remain/scheduler/StoreRemainGenerationScheduler.java @@ -0,0 +1,35 @@ +package com.catchtable.remain.scheduler; + +import com.catchtable.remain.service.StoreRemainService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.LocalDate; + +/** + * 매일 새벽 4시에 "오늘 + 30일" 날짜의 예약 슬롯을 자동 생성한다. + * 항상 오늘 기준 30일치 미래 슬롯이 DB에 존재하도록 슬라이딩 윈도우 방식으로 보충. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class StoreRemainGenerationScheduler { + + private static final int FUTURE_DAYS = 30; + + private final StoreRemainService storeRemainService; + private final Clock clock; + + @Scheduled(cron = "0 0 4 * * *") + public void generateFutureSlots() { + LocalDate targetDate = LocalDate.now(clock).plusDays(FUTURE_DAYS); + int createdStoreCount = storeRemainService.generateDailySlotsForAllStores(targetDate); + + if (createdStoreCount > 0) { + log.info("[슬롯 자동 생성] 대상 날짜: {}, {}개 매장에 슬롯 생성", targetDate, createdStoreCount); + } + } +} diff --git a/src/main/java/com/catchtable/remain/service/StoreRemainService.java b/src/main/java/com/catchtable/remain/service/StoreRemainService.java index 8e3524f..3962353 100644 --- a/src/main/java/com/catchtable/remain/service/StoreRemainService.java +++ b/src/main/java/com/catchtable/remain/service/StoreRemainService.java @@ -35,56 +35,43 @@ public void generateMonthlyRemain(StoreRemainCreateRequestDto request) { Store store = storeRepository.findById(request.storeId()) .orElseThrow(() -> new CustomException(ErrorCode.STORE_NOT_FOUND)); - // 매장의 문자열 형태("10:00") 영업 시간을 LocalTime으로 파싱 DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm"); LocalTime openTime; LocalTime closeTime; - + try { openTime = LocalTime.parse(store.getOpenTime(), formatter); closeTime = LocalTime.parse(store.getCloseTime(), formatter); } catch (Exception e) { - log.error("매장 영업 시간 파싱 실패. storeId: {}, openTime: {}, closeTime: {}", + log.error("매장 영업 시간 파싱 실패. storeId: {}, openTime: {}, closeTime: {}", store.getId(), store.getOpenTime(), store.getCloseTime()); throw new CustomException(ErrorCode.BAD_REQUEST); } - // 해당 월의 1일과 말일 계산 YearMonth targetMonth = YearMonth.of(request.year(), request.month()); LocalDate startDate = targetMonth.atDay(1); LocalDate endDate = targetMonth.atEndOfMonth(); List remainsToSave = new ArrayList<>(); - // 1일부터 말일까지 생성 for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { - LocalTime currentTime = openTime; - - // 시간별 루프 (오픈 시간부터 마감 시간 1시간 전까지) while (currentTime.isBefore(closeTime)) { - if (currentTime.plusHours(1).isAfter(closeTime)) { break; } - - // 엔티티 생성 (잔여 팀 수는 매장의 team) StoreRemain remain = StoreRemain.builder() .store(store) .remainDate(date) .remainTime(currentTime) .remainTeam(store.getTeam()) .build(); - remainsToSave.add(remain); - currentTime = currentTime.plusHours(1); } } - // 저장 storeRemainRepository.saveAll(remainsToSave); - } @Transactional(readOnly = true) @@ -105,6 +92,54 @@ public List getStoreRemains(Long storeId, LocalDate date @Transactional(readOnly = true) public Optional findAvailableRemain(String storeName, LocalDate date, LocalTime time) { return storeRemainRepository.findByStoreNameAndDateTime(storeName, date, time) - .filter(remain -> remain.getRemainTeam() > 0); // 잔여 팀이 1 이상인 경우만 필터링 + .filter(remain -> remain.getRemainTeam() > 0); + } + + /** + * 모든 활성 매장에 대해 지정된 날짜의 슬롯을 생성한다. + * 이미 그 날짜 슬롯이 하나라도 있는 매장은 스킵. + * + * @return 슬롯이 생성된 매장 수 + */ + @Transactional + public int generateDailySlotsForAllStores(LocalDate targetDate) { + List stores = storeRepository.findAllByIsDeletedFalse(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm"); + + int createdStoreCount = 0; + for (Store store : stores) { + if (storeRemainRepository.existsByStoreIdAndRemainDate(store.getId(), targetDate)) { + continue; + } + + try { + LocalTime openTime = LocalTime.parse(store.getOpenTime(), formatter); + LocalTime closeTime = LocalTime.parse(store.getCloseTime(), formatter); + + List slots = new ArrayList<>(); + LocalTime currentTime = openTime; + while (currentTime.isBefore(closeTime)) { + if (currentTime.plusHours(1).isAfter(closeTime)) { + break; + } + slots.add(StoreRemain.builder() + .store(store) + .remainDate(targetDate) + .remainTime(currentTime) + .remainTeam(store.getTeam()) + .build()); + currentTime = currentTime.plusHours(1); + } + + if (!slots.isEmpty()) { + storeRemainRepository.saveAll(slots); + createdStoreCount++; + } + } catch (Exception e) { + log.error("[슬롯 자동 생성 실패] storeId={}, targetDate={}, error={}", + store.getId(), targetDate, e.getMessage()); + } + } + return createdStoreCount; } -} +} \ No newline at end of file From b0035262a8a5f8db1fdb43f4598a211a756b2987 Mon Sep 17 00:00:00 2001 From: johe00123 Date: Wed, 13 May 2026 18:21:07 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20ReservationService=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=ED=95=B4=EA=B2=B0=20=EC=8B=9C=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=EB=90=9C=20import=20=EB=B0=8F=20=EB=B3=80=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/catchtable/reservation/service/ReservationService.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/catchtable/reservation/service/ReservationService.java b/src/main/java/com/catchtable/reservation/service/ReservationService.java index 6f266be..a0bc7ed 100644 --- a/src/main/java/com/catchtable/reservation/service/ReservationService.java +++ b/src/main/java/com/catchtable/reservation/service/ReservationService.java @@ -6,6 +6,7 @@ import com.catchtable.global.exception.ErrorCode; import com.catchtable.notification.event.ReservationCanceledEvent; import com.catchtable.notification.event.ReservationChangedEvent; +import com.catchtable.notification.event.ReservationConfirmedEvent; import com.catchtable.notification.event.ReservationVisitedEvent; import com.catchtable.notification.event.VacancyEvent; import com.catchtable.payment.entity.Payment; @@ -104,6 +105,7 @@ public String createReservationFromAi( @Transactional public ReservationCreateResponseDto create(Long userId, ReservationCreateRequestDto request) { Reservation saved = createReservationCore(userId, request.remainId(), request.member(), request.couponId()); + StoreRemain storeRemain = saved.getStoreRemain(); eventPublisher.publishEvent(new ReservationConfirmedEvent( saved.getId(), From 584a79d3c4c2ace5b4dcdce827875284a07d642f Mon Sep 17 00:00:00 2001 From: johe00123 Date: Fri, 15 May 2026 18:10:07 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20=EC=8A=AC=EB=A1=AF=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=EB=B6=80=EB=B6=84?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=EB=90=9C=20=EC=8A=AC=EB=A1=AF=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존: \"이미 그 날짜 슬롯이 1건이라도 있으면 스킵\" -> 부분 생성 시 나머지 누락 - 변경: 매장별 기대 슬롯과 실제 슬롯을 차집합으로 비교, 부족한 슬롯만 보충 - Repository에 findRemainTimesByStoreIdAndRemainDate 쿼리 추가 - buildSlotTimes 헬퍼 메서드로 슬롯 시간 생성 로직 분리 --- .../catchtable/remain/repository/StoreRemainRepository.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java b/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java index fe63b8a..7ed513a 100644 --- a/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java +++ b/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java @@ -33,4 +33,7 @@ Optional findByStoreNameAndDateTime( ); boolean existsByStoreIdAndRemainDate(Long storeId, LocalDate remainDate); + + @Query("SELECT sr.remainTime FROM StoreRemain sr WHERE sr.store.id = :storeId AND sr.remainDate = :date") + List findRemainTimesByStoreIdAndRemainDate(@Param("storeId") Long storeId, @Param("date") LocalDate date); } From 44f49e6d08863e7410f38738b830d278b04f282d Mon Sep 17 00:00:00 2001 From: johe00123 Date: Fri, 15 May 2026 18:10:39 +0900 Subject: [PATCH 4/8] =?UTF-8?q?refactor:=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=EB=A5=BC=20=EB=8B=A8=EC=9D=BC=20=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=2030=EC=9D=BC=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=EB=A3=A8=ED=94=84=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존: 오늘+30일 단 하루치만 생성 -> 첫 실행 시 사이 날짜 비어있음 - 변경: 오늘 ~ 오늘+29일(총 30일) 범위를 매일 순회하며 보충 - 차집합 중복 방지 로직 덕분에 매일 돌아도 부담 없음 --- .../StoreRemainGenerationScheduler.java | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/catchtable/remain/scheduler/StoreRemainGenerationScheduler.java b/src/main/java/com/catchtable/remain/scheduler/StoreRemainGenerationScheduler.java index bbc68cb..aa16eb0 100644 --- a/src/main/java/com/catchtable/remain/scheduler/StoreRemainGenerationScheduler.java +++ b/src/main/java/com/catchtable/remain/scheduler/StoreRemainGenerationScheduler.java @@ -10,26 +10,32 @@ import java.time.LocalDate; /** - * 매일 새벽 4시에 "오늘 + 30일" 날짜의 예약 슬롯을 자동 생성한다. - * 항상 오늘 기준 30일치 미래 슬롯이 DB에 존재하도록 슬라이딩 윈도우 방식으로 보충. + * 매일 새벽 4시에 "오늘 포함 30일" 범위의 예약 슬롯을 자동 생성한다. + * 항상 오늘부터 30일치 슬롯이 DB에 존재하도록 보충 (차집합 기반이라 이미 있는 날짜는 스킵). */ @Slf4j @Component @RequiredArgsConstructor public class StoreRemainGenerationScheduler { - private static final int FUTURE_DAYS = 30; + private static final int RANGE_DAYS = 30; private final StoreRemainService storeRemainService; private final Clock clock; @Scheduled(cron = "0 0 4 * * *") public void generateFutureSlots() { - LocalDate targetDate = LocalDate.now(clock).plusDays(FUTURE_DAYS); - int createdStoreCount = storeRemainService.generateDailySlotsForAllStores(targetDate); + LocalDate startDate = LocalDate.now(clock); + LocalDate endDate = startDate.plusDays(RANGE_DAYS - 1); - if (createdStoreCount > 0) { - log.info("[슬롯 자동 생성] 대상 날짜: {}, {}개 매장에 슬롯 생성", targetDate, createdStoreCount); + int totalFilledStores = 0; + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + totalFilledStores += storeRemainService.generateDailySlotsForAllStores(date); + } + + if (totalFilledStores > 0) { + log.info("[슬롯 자동 생성] 범위: {} ~ {}, 보충 발생 매장-일 합계: {}", + startDate, endDate, totalFilledStores); } } } From 0e6d093685f55016b6cb1d04741d19d606d1f802 Mon Sep 17 00:00:00 2001 From: johe00123 Date: Fri, 15 May 2026 18:11:34 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=EC=8A=AC=EB=A1=AF=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=EB=B6=80=EB=B6=84?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=EB=90=9C=20=EC=8A=AC=EB=A1=AF=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존: \"이미 그 날짜 슬롯이 1건이라도 있으면 스킵\" -> 부분 생성 시 나머지 누락 - 변경: 매장별 기대 슬롯과 실제 슬롯을 차집합으로 비교, 부족한 슬롯만 보충 - Repository에 findRemainTimesByStoreIdAndRemainDate 쿼리 추가 - buildSlotTimes 헬퍼 메서드로 슬롯 시간 생성 로직 분리 - 파일 이름: StoreRemainService.java --- .../remain/service/StoreRemainService.java | 64 +++++++++++++------ 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/catchtable/remain/service/StoreRemainService.java b/src/main/java/com/catchtable/remain/service/StoreRemainService.java index 3962353..aa61e32 100644 --- a/src/main/java/com/catchtable/remain/service/StoreRemainService.java +++ b/src/main/java/com/catchtable/remain/service/StoreRemainService.java @@ -18,8 +18,10 @@ import java.time.YearMonth; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.Set; @Slf4j @Service @@ -97,49 +99,73 @@ public Optional findAvailableRemain(String storeName, LocalDate dat /** * 모든 활성 매장에 대해 지정된 날짜의 슬롯을 생성한다. - * 이미 그 날짜 슬롯이 하나라도 있는 매장은 스킵. + * 매장별로 기대 슬롯과 실제 슬롯을 비교하여 부족한 슬롯만 채워 넣는다. * - * @return 슬롯이 생성된 매장 수 + * @return 슬롯이 보충된 매장 수 */ @Transactional public int generateDailySlotsForAllStores(LocalDate targetDate) { List stores = storeRepository.findAllByIsDeletedFalse(); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm"); - int createdStoreCount = 0; + int filledStoreCount = 0; for (Store store : stores) { - if (storeRemainRepository.existsByStoreIdAndRemainDate(store.getId(), targetDate)) { - continue; - } - try { LocalTime openTime = LocalTime.parse(store.getOpenTime(), formatter); LocalTime closeTime = LocalTime.parse(store.getCloseTime(), formatter); - List slots = new ArrayList<>(); - LocalTime currentTime = openTime; - while (currentTime.isBefore(closeTime)) { - if (currentTime.plusHours(1).isAfter(closeTime)) { - break; + List expectedTimes = buildSlotTimes(openTime, closeTime); + if (expectedTimes.isEmpty()) { + continue; + } + + Set existingTimes = new HashSet<>( + storeRemainRepository.findRemainTimesByStoreIdAndRemainDate(store.getId(), targetDate) + ); + + List missingSlots = new ArrayList<>(); + for (LocalTime time : expectedTimes) { + if (existingTimes.contains(time)) { + continue; } - slots.add(StoreRemain.builder() + missingSlots.add(StoreRemain.builder() .store(store) .remainDate(targetDate) - .remainTime(currentTime) + .remainTime(time) .remainTeam(store.getTeam()) .build()); - currentTime = currentTime.plusHours(1); } - if (!slots.isEmpty()) { - storeRemainRepository.saveAll(slots); - createdStoreCount++; + if (missingSlots.isEmpty()) { + continue; } + + storeRemainRepository.saveAll(missingSlots); + filledStoreCount++; + log.info("[슬롯 보충] storeId={}, targetDate={}, 예상={}, 실제={}, 생성={}", + store.getId(), targetDate, expectedTimes.size(), existingTimes.size(), missingSlots.size()); } catch (Exception e) { log.error("[슬롯 자동 생성 실패] storeId={}, targetDate={}, error={}", store.getId(), targetDate, e.getMessage()); } } - return createdStoreCount; + return filledStoreCount; + } + + /** + * 영업시간 범위로 1시간 단위 슬롯 시간 목록을 생성한다. + * 마지막 슬롯은 closeTime을 넘지 않아야 한다. + */ + private List buildSlotTimes(LocalTime openTime, LocalTime closeTime) { + List times = new ArrayList<>(); + LocalTime current = openTime; + while (current.isBefore(closeTime)) { + if (current.plusHours(1).isAfter(closeTime)) { + break; + } + times.add(current); + current = current.plusHours(1); + } + return times; } } \ No newline at end of file From c1e93282e5d8386c6e361cd7645c6f926112fb15 Mon Sep 17 00:00:00 2001 From: johe00123 Date: Fri, 15 May 2026 18:11:55 +0900 Subject: [PATCH 6/8] =?UTF-8?q?chore:=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=EB=8F=84=EC=9E=85=20=ED=9B=84=20=EB=AF=B8=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EC=88=98=EB=8F=99=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EC=A3=BC=EC=84=9D=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/v1/remains: Swagger 테스트용 수동 슬롯 생성 엔드포인트 - 스케줄러로 자동화되어 사용처가 없어졌으나 시딩/복구 시 부활 가능하도록 주석만 처리 --- .../controller/StoreRemainController.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/catchtable/remain/controller/StoreRemainController.java b/src/main/java/com/catchtable/remain/controller/StoreRemainController.java index 50c8a0d..46f3f77 100644 --- a/src/main/java/com/catchtable/remain/controller/StoreRemainController.java +++ b/src/main/java/com/catchtable/remain/controller/StoreRemainController.java @@ -21,16 +21,16 @@ public class StoreRemainController { private final StoreRemainService storeRemainService; - //나중에 스케줄러 - @PostMapping - public ResponseEntity> generateMonthlyRemain( - @Valid @RequestBody StoreRemainCreateRequestDto request - ) { - storeRemainService.generateMonthlyRemain(request); - return ResponseEntity - .status(SuccessCode.REMAIN_CREATE_SUCCESS.getHttpStatus()) - .body(ApiResponse.success(SuccessCode.REMAIN_CREATE_SUCCESS)); - } +// 스케줄러 도입 후 사용 안 함. 기존 swagger 에서 수동으로 생성하던 POST /api/v1/remains 주석 처리. +// @PostMapping +// public ResponseEntity> generateMonthlyRemain( +// @Valid @RequestBody StoreRemainCreateRequestDto request +// ) { +// storeRemainService.generateMonthlyRemain(request); +// return ResponseEntity +// .status(SuccessCode.REMAIN_CREATE_SUCCESS.getHttpStatus()) +// .body(ApiResponse.success(SuccessCode.REMAIN_CREATE_SUCCESS)); +// } @GetMapping public ResponseEntity>> getStoreRemains( From 222b5e6e2485831ab529fe051b8fbf650f2faab9 Mon Sep 17 00:00:00 2001 From: johe00123 Date: Fri, 15 May 2026 22:35:06 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20PR=20#64=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 매장 조회를 스케줄러 1회로 통합 (30일 루프 중복 제거) - 슬롯 차집합 N+1 쿼리 제거 (날짜별 일괄 조회 1회로 변경) - 매장 청크(500개) 단위 REQUIRES_NEW 트랜잭션 분리 (StoreRemainSlotWriter) - DateTimeFormatter 클래스 상수화 - generateMonthlyRemain에서 buildSlotTimes 재사용 - 미사용 메서드/변수 제거 (existsByStoreIdAndRemainDate, ReservationService.create 변수) --- .../repository/StoreRemainRepository.java | 6 +- .../StoreRemainGenerationScheduler.java | 8 +- .../remain/service/StoreRemainService.java | 148 ++++++++++++------ .../remain/service/StoreRemainSlotWriter.java | 28 ++++ .../service/ReservationService.java | 1 - 5 files changed, 137 insertions(+), 54 deletions(-) create mode 100644 src/main/java/com/catchtable/remain/service/StoreRemainSlotWriter.java diff --git a/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java b/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java index 7ed513a..4c230ee 100644 --- a/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java +++ b/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java @@ -32,8 +32,6 @@ Optional findByStoreNameAndDateTime( @Param("time") LocalTime time ); - boolean existsByStoreIdAndRemainDate(Long storeId, LocalDate remainDate); - - @Query("SELECT sr.remainTime FROM StoreRemain sr WHERE sr.store.id = :storeId AND sr.remainDate = :date") - List findRemainTimesByStoreIdAndRemainDate(@Param("storeId") Long storeId, @Param("date") LocalDate date); + @Query("SELECT sr.store.id, sr.remainTime FROM StoreRemain sr WHERE sr.remainDate = :date") + List findStoreIdAndTimesByDate(@Param("date") LocalDate date); } diff --git a/src/main/java/com/catchtable/remain/scheduler/StoreRemainGenerationScheduler.java b/src/main/java/com/catchtable/remain/scheduler/StoreRemainGenerationScheduler.java index aa16eb0..f2a25ab 100644 --- a/src/main/java/com/catchtable/remain/scheduler/StoreRemainGenerationScheduler.java +++ b/src/main/java/com/catchtable/remain/scheduler/StoreRemainGenerationScheduler.java @@ -1,6 +1,8 @@ package com.catchtable.remain.scheduler; import com.catchtable.remain.service.StoreRemainService; +import com.catchtable.store.entity.Store; +import com.catchtable.store.repository.StoreRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -8,10 +10,12 @@ import java.time.Clock; import java.time.LocalDate; +import java.util.List; /** * 매일 새벽 4시에 "오늘 포함 30일" 범위의 예약 슬롯을 자동 생성한다. * 항상 오늘부터 30일치 슬롯이 DB에 존재하도록 보충 (차집합 기반이라 이미 있는 날짜는 스킵). + * 매장 목록은 범위 루프 시작 전 단 한 번만 조회해 30회 반복 조회 비용을 제거한다. */ @Slf4j @Component @@ -21,16 +25,18 @@ public class StoreRemainGenerationScheduler { private static final int RANGE_DAYS = 30; private final StoreRemainService storeRemainService; + private final StoreRepository storeRepository; private final Clock clock; @Scheduled(cron = "0 0 4 * * *") public void generateFutureSlots() { + List stores = storeRepository.findAllByIsDeletedFalse(); LocalDate startDate = LocalDate.now(clock); LocalDate endDate = startDate.plusDays(RANGE_DAYS - 1); int totalFilledStores = 0; for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { - totalFilledStores += storeRemainService.generateDailySlotsForAllStores(date); + totalFilledStores += storeRemainService.generateDailySlotsForStores(stores, date); } if (totalFilledStores > 0) { diff --git a/src/main/java/com/catchtable/remain/service/StoreRemainService.java b/src/main/java/com/catchtable/remain/service/StoreRemainService.java index aa61e32..6c65ea4 100644 --- a/src/main/java/com/catchtable/remain/service/StoreRemainService.java +++ b/src/main/java/com/catchtable/remain/service/StoreRemainService.java @@ -18,8 +18,10 @@ import java.time.YearMonth; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; @@ -28,8 +30,12 @@ @RequiredArgsConstructor public class StoreRemainService { + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); + private static final int STORE_CHUNK_SIZE = 500; + private final StoreRemainRepository storeRemainRepository; private final StoreRepository storeRepository; + private final StoreRemainSlotWriter storeRemainSlotWriter; @Transactional public void generateMonthlyRemain(StoreRemainCreateRequestDto request) { @@ -37,13 +43,12 @@ public void generateMonthlyRemain(StoreRemainCreateRequestDto request) { Store store = storeRepository.findById(request.storeId()) .orElseThrow(() -> new CustomException(ErrorCode.STORE_NOT_FOUND)); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm"); LocalTime openTime; LocalTime closeTime; try { - openTime = LocalTime.parse(store.getOpenTime(), formatter); - closeTime = LocalTime.parse(store.getCloseTime(), formatter); + openTime = LocalTime.parse(store.getOpenTime(), TIME_FORMATTER); + closeTime = LocalTime.parse(store.getCloseTime(), TIME_FORMATTER); } catch (Exception e) { log.error("매장 영업 시간 파싱 실패. storeId: {}, openTime: {}, closeTime: {}", store.getId(), store.getOpenTime(), store.getCloseTime()); @@ -54,22 +59,17 @@ public void generateMonthlyRemain(StoreRemainCreateRequestDto request) { LocalDate startDate = targetMonth.atDay(1); LocalDate endDate = targetMonth.atEndOfMonth(); + List slotTimes = buildSlotTimes(openTime, closeTime); List remainsToSave = new ArrayList<>(); for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { - LocalTime currentTime = openTime; - while (currentTime.isBefore(closeTime)) { - if (currentTime.plusHours(1).isAfter(closeTime)) { - break; - } - StoreRemain remain = StoreRemain.builder() + for (LocalTime time : slotTimes) { + remainsToSave.add(StoreRemain.builder() .store(store) .remainDate(date) - .remainTime(currentTime) + .remainTime(time) .remainTeam(store.getTeam()) - .build(); - remainsToSave.add(remain); - currentTime = currentTime.plusHours(1); + .build()); } } @@ -99,59 +99,111 @@ public Optional findAvailableRemain(String storeName, LocalDate dat /** * 모든 활성 매장에 대해 지정된 날짜의 슬롯을 생성한다. - * 매장별로 기대 슬롯과 실제 슬롯을 비교하여 부족한 슬롯만 채워 넣는다. + * 단일 호출 편의용 - 내부적으로 매장 목록을 조회한 뒤 generateDailySlotsForStores에 위임한다. + * 30일 같은 범위 루프에서는 매장 조회 중복을 피하기 위해 generateDailySlotsForStores를 직접 호출하라. * * @return 슬롯이 보충된 매장 수 */ @Transactional public int generateDailySlotsForAllStores(LocalDate targetDate) { List stores = storeRepository.findAllByIsDeletedFalse(); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm"); - - int filledStoreCount = 0; - for (Store store : stores) { - try { - LocalTime openTime = LocalTime.parse(store.getOpenTime(), formatter); - LocalTime closeTime = LocalTime.parse(store.getCloseTime(), formatter); - - List expectedTimes = buildSlotTimes(openTime, closeTime); - if (expectedTimes.isEmpty()) { - continue; - } + return generateDailySlotsForStores(stores, targetDate); + } - Set existingTimes = new HashSet<>( - storeRemainRepository.findRemainTimesByStoreIdAndRemainDate(store.getId(), targetDate) - ); + /** + * 주어진 매장 목록에 대해 지정된 날짜의 슬롯을 생성한다. + * - 해당 날짜의 기존 슬롯을 단 1회 조회하여 메모리에서 매장별 차집합 계산 (N+1 쿼리 제거) + * - 매장 N개씩 청크 단위 트랜잭션(REQUIRES_NEW)으로 저장 + * -> 청크 안에서 batch_size 효과 발휘 + 청크 단위 실패 격리 + * + * @return 슬롯이 보충된 매장 수 + */ + public int generateDailySlotsForStores(List stores, LocalDate targetDate) { + Map> existingByStore = fetchExistingTimesByStore(targetDate); - List missingSlots = new ArrayList<>(); - for (LocalTime time : expectedTimes) { - if (existingTimes.contains(time)) { - continue; + int filledStoreCount = 0; + for (int i = 0; i < stores.size(); i += STORE_CHUNK_SIZE) { + int end = Math.min(i + STORE_CHUNK_SIZE, stores.size()); + List chunk = stores.subList(i, end); + + List chunkMissingSlots = new ArrayList<>(); + int chunkFilledCount = 0; + + for (Store store : chunk) { + try { + List storeMissing = buildMissingSlotsForStore(store, targetDate, existingByStore); + if (!storeMissing.isEmpty()) { + chunkMissingSlots.addAll(storeMissing); + chunkFilledCount++; } - missingSlots.add(StoreRemain.builder() - .store(store) - .remainDate(targetDate) - .remainTime(time) - .remainTeam(store.getTeam()) - .build()); + } catch (Exception e) { + log.error("[슬롯 계산 실패] storeId={}, targetDate={}, error={}", + store.getId(), targetDate, e.getMessage()); } + } - if (missingSlots.isEmpty()) { - continue; - } + if (chunkMissingSlots.isEmpty()) { + continue; + } - storeRemainRepository.saveAll(missingSlots); - filledStoreCount++; - log.info("[슬롯 보충] storeId={}, targetDate={}, 예상={}, 실제={}, 생성={}", - store.getId(), targetDate, expectedTimes.size(), existingTimes.size(), missingSlots.size()); + try { + storeRemainSlotWriter.saveSlots(chunkMissingSlots); + filledStoreCount += chunkFilledCount; + log.info("[슬롯 청크 저장] targetDate={}, 매장범위=[{}~{}], 매장수={}, 슬롯수={}", + targetDate, chunk.get(0).getId(), chunk.get(chunk.size() - 1).getId(), + chunkFilledCount, chunkMissingSlots.size()); } catch (Exception e) { - log.error("[슬롯 자동 생성 실패] storeId={}, targetDate={}, error={}", - store.getId(), targetDate, e.getMessage()); + log.error("[슬롯 청크 저장 실패] targetDate={}, 매장범위=[{}~{}], error={}", + targetDate, chunk.get(0).getId(), chunk.get(chunk.size() - 1).getId(), + e.getMessage()); } } return filledStoreCount; } + /** + * 매장 1개에 대해 부족한 슬롯 목록을 메모리에서 계산한다 (DB 접근 없음). + */ + private List buildMissingSlotsForStore( + Store store, LocalDate targetDate, Map> existingByStore) { + LocalTime openTime = LocalTime.parse(store.getOpenTime(), TIME_FORMATTER); + LocalTime closeTime = LocalTime.parse(store.getCloseTime(), TIME_FORMATTER); + + List expectedTimes = buildSlotTimes(openTime, closeTime); + if (expectedTimes.isEmpty()) { + return List.of(); + } + + Set existingTimes = existingByStore.getOrDefault(store.getId(), Set.of()); + + List missingSlots = new ArrayList<>(); + for (LocalTime time : expectedTimes) { + if (existingTimes.contains(time)) { + continue; + } + missingSlots.add(StoreRemain.builder() + .store(store) + .remainDate(targetDate) + .remainTime(time) + .remainTeam(store.getTeam()) + .build()); + } + return missingSlots; + } + + /** + * 지정된 날짜의 모든 매장 슬롯 시간을 단 1회 쿼리로 조회하여 매장별 Set으로 묶는다. + */ + private Map> fetchExistingTimesByStore(LocalDate targetDate) { + Map> map = new HashMap<>(); + for (Object[] row : storeRemainRepository.findStoreIdAndTimesByDate(targetDate)) { + Long storeId = (Long) row[0]; + LocalTime time = (LocalTime) row[1]; + map.computeIfAbsent(storeId, k -> new HashSet<>()).add(time); + } + return map; + } + /** * 영업시간 범위로 1시간 단위 슬롯 시간 목록을 생성한다. * 마지막 슬롯은 closeTime을 넘지 않아야 한다. diff --git a/src/main/java/com/catchtable/remain/service/StoreRemainSlotWriter.java b/src/main/java/com/catchtable/remain/service/StoreRemainSlotWriter.java new file mode 100644 index 0000000..7223a0f --- /dev/null +++ b/src/main/java/com/catchtable/remain/service/StoreRemainSlotWriter.java @@ -0,0 +1,28 @@ +package com.catchtable.remain.service; + +import com.catchtable.remain.entity.StoreRemain; +import com.catchtable.remain.repository.StoreRemainRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 슬롯을 매장 단위 독립 트랜잭션(REQUIRES_NEW)으로 저장한다. + * 한 매장 실패 시 해당 매장만 롤백되고 나머지 매장에 영향을 주지 않는다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class StoreRemainSlotWriter { + + private final StoreRemainRepository storeRemainRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void saveSlots(List missingSlots) { + storeRemainRepository.saveAll(missingSlots); + } +} diff --git a/src/main/java/com/catchtable/reservation/service/ReservationService.java b/src/main/java/com/catchtable/reservation/service/ReservationService.java index 139083b..26b24d9 100644 --- a/src/main/java/com/catchtable/reservation/service/ReservationService.java +++ b/src/main/java/com/catchtable/reservation/service/ReservationService.java @@ -110,7 +110,6 @@ public String createReservationFromAi( @Transactional public ReservationCreateResponseDto create(Long userId, ReservationCreateRequestDto request) { Reservation saved = createReservationCore(userId, request.remainId(), request.member(), request.couponId()); - StoreRemain storeRemain = saved.getStoreRemain(); // ConfirmedEvent는 결제 완료 시점(PaymentService.confirmPayment)에서 발행한다. String orderId = "CATCH-" + saved.getId() + "-" + System.currentTimeMillis(); From 770140f96b8e5af0bd1ef5ccf53a03964055ba94 Mon Sep 17 00:00:00 2001 From: johe00123 Date: Fri, 15 May 2026 23:04:23 +0900 Subject: [PATCH 8/8] =?UTF-8?q?refactor:=20PR=20#65=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20-=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=EB=A6=AC=20=EC=95=88=EC=A0=84=EC=84=B1,=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=95=88=EC=A0=84=EC=84=B1,=20=EB=B0=98=EB=B3=B5?= =?UTF-8?q?=20=EC=97=B0=EC=82=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 청크 단위로 기존 슬롯 조회하도록 변경하여 OOM 위험 제거 (메모리 ~95MB -> ~0.4MB) - Object[] 반환 대신 StoreRemainTimeView Projection으로 타입 안전성 확보 - StoreSlotPlan record로 매장 영업시간 파싱/슬롯 시간 계산을 1회로 단축 (파싱 호출 354만회 -> 11.8만회) - store_remain 테이블에 (remain_date, store_id), (store_id, remain_date) 복합 인덱스 추가 --- .../dto/projection/StoreRemainTimeView.java | 14 +++ .../catchtable/remain/entity/StoreRemain.java | 8 +- .../repository/StoreRemainRepository.java | 8 +- .../StoreRemainGenerationScheduler.java | 8 +- .../remain/service/StoreRemainService.java | 99 +++++++++++-------- .../remain/service/StoreSlotPlan.java | 14 +++ 6 files changed, 103 insertions(+), 48 deletions(-) create mode 100644 src/main/java/com/catchtable/remain/dto/projection/StoreRemainTimeView.java create mode 100644 src/main/java/com/catchtable/remain/service/StoreSlotPlan.java diff --git a/src/main/java/com/catchtable/remain/dto/projection/StoreRemainTimeView.java b/src/main/java/com/catchtable/remain/dto/projection/StoreRemainTimeView.java new file mode 100644 index 0000000..2fe1ab8 --- /dev/null +++ b/src/main/java/com/catchtable/remain/dto/projection/StoreRemainTimeView.java @@ -0,0 +1,14 @@ +package com.catchtable.remain.dto.projection; + +import java.time.LocalTime; + +/** + * 매장별 슬롯 시간 조회용 Interface Projection. + * (storeId, remainTime) 페어를 타입 안전하게 반환한다. + */ +public interface StoreRemainTimeView { + + Long getStoreId(); + + LocalTime getRemainTime(); +} diff --git a/src/main/java/com/catchtable/remain/entity/StoreRemain.java b/src/main/java/com/catchtable/remain/entity/StoreRemain.java index db62cc0..1520c9d 100644 --- a/src/main/java/com/catchtable/remain/entity/StoreRemain.java +++ b/src/main/java/com/catchtable/remain/entity/StoreRemain.java @@ -18,7 +18,13 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity -@Table(name = "store_remain") +@Table( + name = "store_remain", + indexes = { + @Index(name = "idx_store_remain_date_store", columnList = "remain_date, store_id"), + @Index(name = "idx_store_remain_store_date", columnList = "store_id, remain_date") + } +) public class StoreRemain { @Id diff --git a/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java b/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java index 4c230ee..b9c00f5 100644 --- a/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java +++ b/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java @@ -1,5 +1,6 @@ package com.catchtable.remain.repository; +import com.catchtable.remain.dto.projection.StoreRemainTimeView; import com.catchtable.remain.entity.StoreRemain; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -32,6 +33,9 @@ Optional findByStoreNameAndDateTime( @Param("time") LocalTime time ); - @Query("SELECT sr.store.id, sr.remainTime FROM StoreRemain sr WHERE sr.remainDate = :date") - List findStoreIdAndTimesByDate(@Param("date") LocalDate date); + @Query("SELECT sr.store.id AS storeId, sr.remainTime AS remainTime FROM StoreRemain sr " + + "WHERE sr.remainDate = :date AND sr.store.id IN :storeIds") + List findStoreIdAndTimesByDateAndStoreIds( + @Param("date") LocalDate date, + @Param("storeIds") List storeIds); } diff --git a/src/main/java/com/catchtable/remain/scheduler/StoreRemainGenerationScheduler.java b/src/main/java/com/catchtable/remain/scheduler/StoreRemainGenerationScheduler.java index f2a25ab..7847282 100644 --- a/src/main/java/com/catchtable/remain/scheduler/StoreRemainGenerationScheduler.java +++ b/src/main/java/com/catchtable/remain/scheduler/StoreRemainGenerationScheduler.java @@ -1,6 +1,7 @@ package com.catchtable.remain.scheduler; import com.catchtable.remain.service.StoreRemainService; +import com.catchtable.remain.service.StoreSlotPlan; import com.catchtable.store.entity.Store; import com.catchtable.store.repository.StoreRepository; import lombok.RequiredArgsConstructor; @@ -15,7 +16,8 @@ /** * 매일 새벽 4시에 "오늘 포함 30일" 범위의 예약 슬롯을 자동 생성한다. * 항상 오늘부터 30일치 슬롯이 DB에 존재하도록 보충 (차집합 기반이라 이미 있는 날짜는 스킵). - * 매장 목록은 범위 루프 시작 전 단 한 번만 조회해 30회 반복 조회 비용을 제거한다. + * 매장 목록 조회와 영업시간 파싱은 범위 루프 시작 전 단 한 번만 수행해 + * 30회 반복 조회/파싱 비용을 제거한다. */ @Slf4j @Component @@ -31,12 +33,14 @@ public class StoreRemainGenerationScheduler { @Scheduled(cron = "0 0 4 * * *") public void generateFutureSlots() { List stores = storeRepository.findAllByIsDeletedFalse(); + List plans = storeRemainService.buildSlotPlans(stores); + LocalDate startDate = LocalDate.now(clock); LocalDate endDate = startDate.plusDays(RANGE_DAYS - 1); int totalFilledStores = 0; for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { - totalFilledStores += storeRemainService.generateDailySlotsForStores(stores, date); + totalFilledStores += storeRemainService.generateDailySlotsForPlans(plans, date); } if (totalFilledStores > 0) { diff --git a/src/main/java/com/catchtable/remain/service/StoreRemainService.java b/src/main/java/com/catchtable/remain/service/StoreRemainService.java index 6c65ea4..ceaf67d 100644 --- a/src/main/java/com/catchtable/remain/service/StoreRemainService.java +++ b/src/main/java/com/catchtable/remain/service/StoreRemainService.java @@ -2,6 +2,7 @@ import com.catchtable.remain.dto.read.StoreRemainResponseDto; import com.catchtable.remain.dto.create.StoreRemainCreateRequestDto; +import com.catchtable.remain.dto.projection.StoreRemainTimeView; import com.catchtable.remain.entity.StoreRemain; import com.catchtable.remain.repository.StoreRemainRepository; import com.catchtable.global.exception.CustomException; @@ -99,46 +100,66 @@ public Optional findAvailableRemain(String storeName, LocalDate dat /** * 모든 활성 매장에 대해 지정된 날짜의 슬롯을 생성한다. - * 단일 호출 편의용 - 내부적으로 매장 목록을 조회한 뒤 generateDailySlotsForStores에 위임한다. - * 30일 같은 범위 루프에서는 매장 조회 중복을 피하기 위해 generateDailySlotsForStores를 직접 호출하라. + * 단일 호출 편의용 - 매장 목록 조회 + 영업시간 사전 계산 후 generateDailySlotsForPlans에 위임한다. + * 30일 같은 범위 루프에서는 buildSlotPlans를 1회 호출 후 generateDailySlotsForPlans를 직접 사용하라. * * @return 슬롯이 보충된 매장 수 */ @Transactional public int generateDailySlotsForAllStores(LocalDate targetDate) { List stores = storeRepository.findAllByIsDeletedFalse(); - return generateDailySlotsForStores(stores, targetDate); + return generateDailySlotsForPlans(buildSlotPlans(stores), targetDate); } /** - * 주어진 매장 목록에 대해 지정된 날짜의 슬롯을 생성한다. - * - 해당 날짜의 기존 슬롯을 단 1회 조회하여 메모리에서 매장별 차집합 계산 (N+1 쿼리 제거) - * - 매장 N개씩 청크 단위 트랜잭션(REQUIRES_NEW)으로 저장 - * -> 청크 안에서 batch_size 효과 발휘 + 청크 단위 실패 격리 + * 매장 목록을 받아 매장별 슬롯 계획(영업시간 파싱 + 시간 슬롯 목록)을 1회 계산한다. + * 스케줄러가 30일 루프 시작 전 1회 호출하면 동일 매장에 대한 반복 파싱을 제거할 수 있다. + * 영업시간 파싱 실패 매장은 결과에서 제외한다. + */ + public List buildSlotPlans(List stores) { + List plans = new ArrayList<>(stores.size()); + for (Store store : stores) { + try { + LocalTime openTime = LocalTime.parse(store.getOpenTime(), TIME_FORMATTER); + LocalTime closeTime = LocalTime.parse(store.getCloseTime(), TIME_FORMATTER); + List expectedTimes = buildSlotTimes(openTime, closeTime); + if (expectedTimes.isEmpty()) { + continue; + } + plans.add(new StoreSlotPlan(store, expectedTimes)); + } catch (Exception e) { + log.error("[영업시간 파싱 실패] storeId={}, openTime={}, closeTime={}", + store.getId(), store.getOpenTime(), store.getCloseTime()); + } + } + return plans; + } + + /** + * 사전 계산된 슬롯 계획에 따라 지정된 날짜의 슬롯을 생성한다. + * - 매장 N개씩 청크 단위로 기존 슬롯을 조회하고 저장하여 메모리 부담 최소화 + * - 청크 단위 REQUIRES_NEW 트랜잭션으로 batch_size 효과 + 실패 격리 동시 확보 + * - 영업시간 파싱/슬롯 계산은 buildSlotPlans에서 이미 완료되어 루프마다 반복하지 않는다. * * @return 슬롯이 보충된 매장 수 */ - public int generateDailySlotsForStores(List stores, LocalDate targetDate) { - Map> existingByStore = fetchExistingTimesByStore(targetDate); - + public int generateDailySlotsForPlans(List plans, LocalDate targetDate) { int filledStoreCount = 0; - for (int i = 0; i < stores.size(); i += STORE_CHUNK_SIZE) { - int end = Math.min(i + STORE_CHUNK_SIZE, stores.size()); - List chunk = stores.subList(i, end); + for (int i = 0; i < plans.size(); i += STORE_CHUNK_SIZE) { + int end = Math.min(i + STORE_CHUNK_SIZE, plans.size()); + List chunk = plans.subList(i, end); + + List chunkStoreIds = chunk.stream().map(p -> p.store().getId()).toList(); + Map> existingByStore = fetchExistingTimesByStores(chunkStoreIds, targetDate); List chunkMissingSlots = new ArrayList<>(); int chunkFilledCount = 0; - for (Store store : chunk) { - try { - List storeMissing = buildMissingSlotsForStore(store, targetDate, existingByStore); - if (!storeMissing.isEmpty()) { - chunkMissingSlots.addAll(storeMissing); - chunkFilledCount++; - } - } catch (Exception e) { - log.error("[슬롯 계산 실패] storeId={}, targetDate={}, error={}", - store.getId(), targetDate, e.getMessage()); + for (StoreSlotPlan plan : chunk) { + List storeMissing = buildMissingSlotsForPlan(plan, targetDate, existingByStore); + if (!storeMissing.isEmpty()) { + chunkMissingSlots.addAll(storeMissing); + chunkFilledCount++; } } @@ -150,11 +171,11 @@ public int generateDailySlotsForStores(List stores, LocalDate targetDate) storeRemainSlotWriter.saveSlots(chunkMissingSlots); filledStoreCount += chunkFilledCount; log.info("[슬롯 청크 저장] targetDate={}, 매장범위=[{}~{}], 매장수={}, 슬롯수={}", - targetDate, chunk.get(0).getId(), chunk.get(chunk.size() - 1).getId(), + targetDate, chunk.get(0).store().getId(), chunk.get(chunk.size() - 1).store().getId(), chunkFilledCount, chunkMissingSlots.size()); } catch (Exception e) { log.error("[슬롯 청크 저장 실패] targetDate={}, 매장범위=[{}~{}], error={}", - targetDate, chunk.get(0).getId(), chunk.get(chunk.size() - 1).getId(), + targetDate, chunk.get(0).store().getId(), chunk.get(chunk.size() - 1).store().getId(), e.getMessage()); } } @@ -162,22 +183,15 @@ public int generateDailySlotsForStores(List stores, LocalDate targetDate) } /** - * 매장 1개에 대해 부족한 슬롯 목록을 메모리에서 계산한다 (DB 접근 없음). + * 사전 계산된 매장 슬롯 계획에 대해 부족한 슬롯 목록을 메모리에서 계산한다 (DB 접근 없음). */ - private List buildMissingSlotsForStore( - Store store, LocalDate targetDate, Map> existingByStore) { - LocalTime openTime = LocalTime.parse(store.getOpenTime(), TIME_FORMATTER); - LocalTime closeTime = LocalTime.parse(store.getCloseTime(), TIME_FORMATTER); - - List expectedTimes = buildSlotTimes(openTime, closeTime); - if (expectedTimes.isEmpty()) { - return List.of(); - } - + private List buildMissingSlotsForPlan( + StoreSlotPlan plan, LocalDate targetDate, Map> existingByStore) { + Store store = plan.store(); Set existingTimes = existingByStore.getOrDefault(store.getId(), Set.of()); List missingSlots = new ArrayList<>(); - for (LocalTime time : expectedTimes) { + for (LocalTime time : plan.expectedTimes()) { if (existingTimes.contains(time)) { continue; } @@ -192,14 +206,13 @@ private List buildMissingSlotsForStore( } /** - * 지정된 날짜의 모든 매장 슬롯 시간을 단 1회 쿼리로 조회하여 매장별 Set으로 묶는다. + * 지정된 매장 ID 목록과 날짜에 해당하는 기존 슬롯 시간을 한 번에 조회하여 매장별 Set으로 묶는다. + * 청크 범위로 한정하여 메모리 사용량을 매장 전체가 아닌 청크 크기에 비례하도록 제한한다. */ - private Map> fetchExistingTimesByStore(LocalDate targetDate) { + private Map> fetchExistingTimesByStores(List storeIds, LocalDate targetDate) { Map> map = new HashMap<>(); - for (Object[] row : storeRemainRepository.findStoreIdAndTimesByDate(targetDate)) { - Long storeId = (Long) row[0]; - LocalTime time = (LocalTime) row[1]; - map.computeIfAbsent(storeId, k -> new HashSet<>()).add(time); + for (StoreRemainTimeView view : storeRemainRepository.findStoreIdAndTimesByDateAndStoreIds(targetDate, storeIds)) { + map.computeIfAbsent(view.getStoreId(), k -> new HashSet<>()).add(view.getRemainTime()); } return map; } diff --git a/src/main/java/com/catchtable/remain/service/StoreSlotPlan.java b/src/main/java/com/catchtable/remain/service/StoreSlotPlan.java new file mode 100644 index 0000000..e985c11 --- /dev/null +++ b/src/main/java/com/catchtable/remain/service/StoreSlotPlan.java @@ -0,0 +1,14 @@ +package com.catchtable.remain.service; + +import com.catchtable.store.entity.Store; + +import java.time.LocalTime; +import java.util.List; + +/** + * 매장 1개에 대해 미리 파싱/계산해 둔 슬롯 계획. + * 영업시간 문자열 파싱과 슬롯 시간 목록 생성을 30일 루프마다 반복하지 않기 위해 + * 스케줄러 시작 시점에 1회 계산해 재사용한다. + */ +public record StoreSlotPlan(Store store, List expectedTimes) { +}