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( 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 9e95850..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; @@ -31,4 +32,10 @@ Optional findByStoreNameAndDateTime( @Param("date") LocalDate date, @Param("time") LocalTime time ); + + @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 new file mode 100644 index 0000000..7847282 --- /dev/null +++ b/src/main/java/com/catchtable/remain/scheduler/StoreRemainGenerationScheduler.java @@ -0,0 +1,51 @@ +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; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; + +/** + * 매일 새벽 4시에 "오늘 포함 30일" 범위의 예약 슬롯을 자동 생성한다. + * 항상 오늘부터 30일치 슬롯이 DB에 존재하도록 보충 (차집합 기반이라 이미 있는 날짜는 스킵). + * 매장 목록 조회와 영업시간 파싱은 범위 루프 시작 전 단 한 번만 수행해 + * 30회 반복 조회/파싱 비용을 제거한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +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(); + 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.generateDailySlotsForPlans(plans, date); + } + + if (totalFilledStores > 0) { + log.info("[슬롯 자동 생성] 범위: {} ~ {}, 보충 발생 매장-일 합계: {}", + startDate, endDate, totalFilledStores); + } + } +} diff --git a/src/main/java/com/catchtable/remain/service/StoreRemainService.java b/src/main/java/com/catchtable/remain/service/StoreRemainService.java index 8e3524f..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; @@ -18,16 +19,24 @@ 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; @Slf4j @Service @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) { @@ -35,56 +44,37 @@ 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); + openTime = LocalTime.parse(store.getOpenTime(), TIME_FORMATTER); + closeTime = LocalTime.parse(store.getCloseTime(), TIME_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 slotTimes = buildSlotTimes(openTime, closeTime); 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() + 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()); } } - // 저장 storeRemainRepository.saveAll(remainsToSave); - } @Transactional(readOnly = true) @@ -105,6 +95,142 @@ 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); + } + + /** + * 모든 활성 매장에 대해 지정된 날짜의 슬롯을 생성한다. + * 단일 호출 편의용 - 매장 목록 조회 + 영업시간 사전 계산 후 generateDailySlotsForPlans에 위임한다. + * 30일 같은 범위 루프에서는 buildSlotPlans를 1회 호출 후 generateDailySlotsForPlans를 직접 사용하라. + * + * @return 슬롯이 보충된 매장 수 + */ + @Transactional + public int generateDailySlotsForAllStores(LocalDate targetDate) { + List stores = storeRepository.findAllByIsDeletedFalse(); + return generateDailySlotsForPlans(buildSlotPlans(stores), targetDate); + } + + /** + * 매장 목록을 받아 매장별 슬롯 계획(영업시간 파싱 + 시간 슬롯 목록)을 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 generateDailySlotsForPlans(List plans, LocalDate targetDate) { + int filledStoreCount = 0; + 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 (StoreSlotPlan plan : chunk) { + List storeMissing = buildMissingSlotsForPlan(plan, targetDate, existingByStore); + if (!storeMissing.isEmpty()) { + chunkMissingSlots.addAll(storeMissing); + chunkFilledCount++; + } + } + + if (chunkMissingSlots.isEmpty()) { + continue; + } + + try { + storeRemainSlotWriter.saveSlots(chunkMissingSlots); + filledStoreCount += chunkFilledCount; + log.info("[슬롯 청크 저장] targetDate={}, 매장범위=[{}~{}], 매장수={}, 슬롯수={}", + 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).store().getId(), chunk.get(chunk.size() - 1).store().getId(), + e.getMessage()); + } + } + return filledStoreCount; + } + + /** + * 사전 계산된 매장 슬롯 계획에 대해 부족한 슬롯 목록을 메모리에서 계산한다 (DB 접근 없음). + */ + 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 : plan.expectedTimes()) { + if (existingTimes.contains(time)) { + continue; + } + missingSlots.add(StoreRemain.builder() + .store(store) + .remainDate(targetDate) + .remainTime(time) + .remainTeam(store.getTeam()) + .build()); + } + return missingSlots; + } + + /** + * 지정된 매장 ID 목록과 날짜에 해당하는 기존 슬롯 시간을 한 번에 조회하여 매장별 Set으로 묶는다. + * 청크 범위로 한정하여 메모리 사용량을 매장 전체가 아닌 청크 크기에 비례하도록 제한한다. + */ + private Map> fetchExistingTimesByStores(List storeIds, LocalDate targetDate) { + Map> map = new HashMap<>(); + for (StoreRemainTimeView view : storeRemainRepository.findStoreIdAndTimesByDateAndStoreIds(targetDate, storeIds)) { + map.computeIfAbsent(view.getStoreId(), k -> new HashSet<>()).add(view.getRemainTime()); + } + return map; + } + + /** + * 영업시간 범위로 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 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/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) { +}