-
Notifications
You must be signed in to change notification settings - Fork 0
[Quiz][Feat] 베스트 셀러 퀴즈 생성 스케줄러 #198
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
36a729a
8572271
1e157bb
9869ae9
02276e9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| package book.book.bestseller.entity; | ||
|
|
||
| import book.book.book.entity.Book; | ||
| import book.book.common.BaseTimeEntity; | ||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.FetchType; | ||
| import jakarta.persistence.GeneratedValue; | ||
| import jakarta.persistence.GenerationType; | ||
| import jakarta.persistence.Id; | ||
| import jakarta.persistence.JoinColumn; | ||
| import jakarta.persistence.ManyToOne; | ||
| import jakarta.persistence.Table; | ||
| import lombok.AccessLevel; | ||
| import lombok.AllArgsConstructor; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Entity | ||
| @Table(name = "best_seller") | ||
| @Getter | ||
| @Builder | ||
| @AllArgsConstructor | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
|
|
||
| public class BestSeller extends BaseTimeEntity { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| private Long id; | ||
|
|
||
| @ManyToOne(fetch = FetchType.LAZY) | ||
| @JoinColumn(name = "book_id", nullable = false, unique = true) | ||
| private Book book; | ||
|
|
||
| @Column(nullable = false) | ||
| @Builder.Default | ||
| private Integer retryCount = 0; | ||
|
|
||
| public void incrementRetryCount() { | ||
| this.retryCount++; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package book.book.bestseller.repository; | ||
|
|
||
| import book.book.bestseller.entity.BestSeller; | ||
| import book.book.book.entity.Book; | ||
| import java.util.List; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
| import org.springframework.data.jpa.repository.Query; | ||
|
|
||
| public interface BestSellerRepository extends JpaRepository<BestSeller, Long> { | ||
|
|
||
| boolean existsByBook(Book book); | ||
|
|
||
| @Query("SELECT b FROM BestSeller b JOIN FETCH b.book ORDER BY b.id ASC") | ||
| List<BestSeller> findTop60(Pageable pageable); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| package book.book.bestseller.scheduler; | ||
|
|
||
| import book.book.bestseller.service.BestsellerService; | ||
| import book.book.search.dto.aladin.AladinSearchResponse; | ||
| import book.book.search.service.AladinService; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.scheduling.annotation.Scheduled; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
|
|
||
| /** | ||
| * 최초 챌린지 생성시 퀴즈가 생성되는데, 퀴즈 생성 시간이 1분이므로 | ||
| * 확률적으로 유저들의 퀴즈 생성 시간을 낮춰주기 위해 베스트 셀러는 미리 퀴즈 생성 | ||
| */ | ||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class BestsellerScheduler { | ||
|
|
||
| private final BestsellerService bestsellerService; | ||
| private final AladinService aladinService; | ||
|
|
||
| /** | ||
| * 매주 일요일 밤 10시에 알라딘 베스트셀러를 가져와 저장 | ||
| */ | ||
| @Scheduled(cron = "0 0 2 * * MON") | ||
| public void fetchBestsellers() { | ||
| int totalSaved = 0; | ||
| // 1페이지부터 20페이지까지 (페이지당 50권) | ||
| for (int page = 1; page <= 20; page++) { | ||
| try { | ||
| AladinSearchResponse response = aladinService.bestsellerSearch(page); | ||
| if (response == null || response.getItem() == null) { | ||
| continue; | ||
| } | ||
|
|
||
| for (AladinSearchResponse.SearchItem item : response.getItem()) { | ||
| try { | ||
| bestsellerService.saveBestSeller(item); | ||
| totalSaved++; | ||
| } catch (Exception e) { | ||
| log.error("베스트셀러 저장 중 오류 발생: {}", item.getTitle(), e); | ||
| } | ||
| } | ||
| } catch (Exception e) { | ||
| log.error("알라딘 베스트셀러 API 호출 중 오류 발생 (page={})", page, e); | ||
| } | ||
| } | ||
| log.info("일요일 베스트셀러 수집 완료. 신규 저장: {}권", totalSaved); | ||
| } | ||
|
|
||
| /** | ||
| * 월요일 오전 3시부터 23시까지 매 시간마다 | ||
| * 베스트 셀러 엔티티에서 꺼내서 퀴즈 생성후 베스트 셀러 삭제 | ||
| */ | ||
| @Scheduled(cron = "0 0 3-23 * * MON") | ||
| public void generateBestsellerQuizzes() { | ||
| bestsellerService.generateBestsellerQuizzes(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| package book.book.bestseller.service; | ||
|
|
||
| import book.book.bestseller.entity.BestSeller; | ||
| import book.book.bestseller.repository.BestSellerRepository; | ||
| import book.book.book.entity.Book; | ||
| import book.book.book.entity.Chapter; | ||
| import book.book.book.repository.ChapterRepository; | ||
| import book.book.book.service.BookSyncService; | ||
| import book.book.common.error_notification.DiscordChannelType; | ||
| import book.book.common.error_notification.SenderToDiscord; | ||
| import book.book.quiz.service.QuizGenerationAsyncService; | ||
| import book.book.search.dto.aladin.AladinSearchResponse; | ||
| import java.util.List; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.data.domain.PageRequest; | ||
| import org.springframework.stereotype.Service; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| @Slf4j | ||
| public class BestsellerService { | ||
|
|
||
| private final BestSellerRepository bestSellerRepository; | ||
| private final BookSyncService bookSyncService; | ||
| private final QuizGenerationAsyncService quizGenerationAsyncService; | ||
| private final ChapterRepository chapterRepository; | ||
| private final SenderToDiscord senderToDiscord; | ||
|
|
||
| private static final int CHUNK_SIZE = 60; | ||
| private static final int MAX_RETRY_COUNT = 3; | ||
|
|
||
| public void saveBestSeller(AladinSearchResponse.SearchItem item) { | ||
| Book book = bookSyncService.findOrElseSaveBook(item); | ||
| if (!bestSellerRepository.existsByBook(book)) { | ||
| bestSellerRepository.save(BestSeller.builder() | ||
| .book(book) | ||
| .retryCount(0) | ||
| .build()); | ||
| } | ||
| } | ||
|
|
||
| public void generateBestsellerQuizzes() { | ||
| List<BestSeller> bestSellers = bestSellerRepository.findTop60(PageRequest.of(0, CHUNK_SIZE)); | ||
|
|
||
| if (bestSellers.isEmpty()) { | ||
| log.info("처리할 베스트셀러가 없습니다. 종료."); | ||
| return; | ||
| } | ||
|
|
||
| log.info("총 {}권의 베스트셀러 처리 시작", bestSellers.size()); | ||
| bestSellers.forEach(this::processBestseller); | ||
| } | ||
|
|
||
| private void processBestseller(BestSeller bestSeller) { | ||
| if (bestSeller.getRetryCount() >= MAX_RETRY_COUNT) { | ||
| handleMaxRetryFailure(bestSeller); | ||
| return; | ||
| } | ||
|
|
||
| Book book = bestSeller.getBook(); | ||
| List<Chapter> chapters = chapterRepository.findByBookIdOrderByChapterNumber(book.getId()); | ||
|
|
||
| if (chapters.isEmpty()) { | ||
| return; | ||
| } | ||
|
Comment on lines
+64
to
+66
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
if (chapters.isEmpty()) {
log.warn("책에 챕터가 없어 퀴즈를 생성할 수 없습니다. 베스트셀러 목록에서 삭제합니다. bookId: {}", book.getId());
bestSellerRepository.delete(bestSeller);
return;
} |
||
|
|
||
| quizGenerationAsyncService.generateQuizzes(book, chapters) | ||
| .thenRun(() -> bestSellerRepository.deleteById(bestSeller.getId())) | ||
| .exceptionally(e -> { | ||
| log.error("퀴즈 생성 실패: {}", book.getTitle(), e); | ||
| handleFailure(bestSeller.getId()); | ||
| return null; | ||
| }); | ||
| } | ||
|
|
||
| private void handleMaxRetryFailure(BestSeller bestSeller) { | ||
| try { | ||
| String bookTitle = bestSeller.getBook().getTitle(); | ||
|
|
||
| String message = String.format("베스트셀러 퀴즈 생성 3회 실패로 인한 삭제: %s (ID: %d)", | ||
| bookTitle, bestSeller.getBook().getId()); | ||
| senderToDiscord.sendLog(DiscordChannelType.CRAWLER_ERROR, "베스트 셀러 퀴즈 생성 3회 실패", message); | ||
|
|
||
| bestSellerRepository.deleteById(bestSeller.getId()); | ||
| } catch (Exception e) { | ||
| log.error("최대 재시도 실패 처리 중 오류", e); | ||
| } | ||
| } | ||
|
|
||
| private void handleFailure(Long bestSellerId) { | ||
| try { | ||
| bestSellerRepository.findById(bestSellerId).ifPresent(bestSeller -> { | ||
| bestSeller.incrementRetryCount(); | ||
| bestSellerRepository.save(bestSeller); | ||
| }); | ||
| } catch (Exception ex) { | ||
| log.error("실패 카운트 증가 중 오류 발생", ex); | ||
| } | ||
| } | ||
|
Comment on lines
+91
to
+100
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 현재 더 안전하고 효율적인 방법은
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.repository.query.Param;
// ...
@Modifying
@Query("UPDATE BestSeller b SET b.retryCount = b.retryCount + 1 WHERE b.id = :id")
void incrementRetryCount(@Param("id") Long id);위 메서드를 추가한 후 private void handleFailure(Long bestSellerId) {
try {
bestSellerRepository.incrementRetryCount(bestSellerId);
} catch (Exception ex) {
log.error("실패 카운트 증가 중 오류 발생: bestSellerId={}", bestSellerId, ex);
}
} |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
스케줄러의 cron 표현식과 주석의 내용이 일치하지 않습니다. 주석에는 "매주 일요일 밤 10시"라고 되어 있지만, cron 표현식
0 0 2 * * MON은 "매주 월요일 새벽 2시"를 의미합니다. 의도한 스케줄 시간에 맞게 주석이나 cron 표현식을 수정하여 혼동을 줄이는 것이 좋겠습니다. 참고로 일요일 밤 10시는0 0 22 * * SUN입니다. 뒤따르는 퀴즈 생성 스케줄이 월요일 새벽 3시부터인 것을 감안할 때, 주석을 수정하는 것이 더 적절해 보입니다.