From 36a729ae7bf5c76d5ca4585991a43a06a719a28e Mon Sep 17 00:00:00 2001 From: sunwon Date: Fri, 12 Dec 2025 14:40:23 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[Quiz][Feat]=20=EB=B2=A0=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=85=80=EB=9F=AC=20=ED=80=B4=EC=A6=88=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book/bestseller/entity/BestSeller.java | 44 +++++ .../repository/BestSellerRepository.java | 16 ++ .../scheduler/BestsellerScheduler.java | 61 +++++++ .../bestseller/service/BestsellerService.java | 101 ++++++++++++ .../DiscordChannelType.java | 3 +- .../book/config/DiscordWebhookConfig.java | 4 + .../service/BestsellerServiceTest.java | 154 ++++++++++++++++++ 7 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 src/main/java/book/book/bestseller/entity/BestSeller.java create mode 100644 src/main/java/book/book/bestseller/repository/BestSellerRepository.java create mode 100644 src/main/java/book/book/bestseller/scheduler/BestsellerScheduler.java create mode 100644 src/main/java/book/book/bestseller/service/BestsellerService.java create mode 100644 src/test/java/book/book/bestseller/service/BestsellerServiceTest.java diff --git a/src/main/java/book/book/bestseller/entity/BestSeller.java b/src/main/java/book/book/bestseller/entity/BestSeller.java new file mode 100644 index 00000000..dc77a4ff --- /dev/null +++ b/src/main/java/book/book/bestseller/entity/BestSeller.java @@ -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++; + } +} diff --git a/src/main/java/book/book/bestseller/repository/BestSellerRepository.java b/src/main/java/book/book/bestseller/repository/BestSellerRepository.java new file mode 100644 index 00000000..b71814fd --- /dev/null +++ b/src/main/java/book/book/bestseller/repository/BestSellerRepository.java @@ -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 { + + boolean existsByBook(Book book); + + @Query("SELECT b FROM BestSeller b JOIN FETCH b.book ORDER BY b.id ASC") + List findTop60(Pageable pageable); +} diff --git a/src/main/java/book/book/bestseller/scheduler/BestsellerScheduler.java b/src/main/java/book/book/bestseller/scheduler/BestsellerScheduler.java new file mode 100644 index 00000000..5181606e --- /dev/null +++ b/src/main/java/book/book/bestseller/scheduler/BestsellerScheduler.java @@ -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(); + } +} diff --git a/src/main/java/book/book/bestseller/service/BestsellerService.java b/src/main/java/book/book/bestseller/service/BestsellerService.java new file mode 100644 index 00000000..f8b3797e --- /dev/null +++ b/src/main/java/book/book/bestseller/service/BestsellerService.java @@ -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 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 chapters = chapterRepository.findByBookIdOrderByChapterNumber(book.getId()); + + if (chapters.isEmpty()) { + 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); + } + } +} diff --git a/src/main/java/book/book/common/error_notification/DiscordChannelType.java b/src/main/java/book/book/common/error_notification/DiscordChannelType.java index 60c64c22..d81c2bc9 100644 --- a/src/main/java/book/book/common/error_notification/DiscordChannelType.java +++ b/src/main/java/book/book/common/error_notification/DiscordChannelType.java @@ -12,7 +12,8 @@ public enum DiscordChannelType { ERROR_NOTIFICATION("에러 알림"), GEMINI_API("제미나이 API"), - QUIZ_FEEDBACK("퀴즈 오류 신고"); + QUIZ_FEEDBACK("퀴즈 오류 신고"), + CRAWLER_ERROR("크롤러 에러"); private final String description; } \ No newline at end of file diff --git a/src/main/java/book/book/config/DiscordWebhookConfig.java b/src/main/java/book/book/config/DiscordWebhookConfig.java index 54c2d16c..ced4ea02 100644 --- a/src/main/java/book/book/config/DiscordWebhookConfig.java +++ b/src/main/java/book/book/config/DiscordWebhookConfig.java @@ -16,6 +16,9 @@ public class DiscordWebhookConfig { @Value("${discord.webhook.quiz-feedback.url:}") private String quizFeedbackWebhookUrl; + @Value("${discord.webhook.crawler-error.url:}") + private String crawlerErrorWebhookUrl; + public String getWebhookUrl() { return errorWebhookUrl; } @@ -25,6 +28,7 @@ public String getWebhookUrl(DiscordChannelType channelType) { case ERROR_NOTIFICATION -> errorWebhookUrl; case GEMINI_API -> geminiWebhookUrl; case QUIZ_FEEDBACK -> quizFeedbackWebhookUrl; + case CRAWLER_ERROR -> crawlerErrorWebhookUrl; }; } } \ No newline at end of file diff --git a/src/test/java/book/book/bestseller/service/BestsellerServiceTest.java b/src/test/java/book/book/bestseller/service/BestsellerServiceTest.java new file mode 100644 index 00000000..6cc4ba64 --- /dev/null +++ b/src/test/java/book/book/bestseller/service/BestsellerServiceTest.java @@ -0,0 +1,154 @@ +package book.book.bestseller.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +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.fixture.BookFixture; +import book.book.book.repository.BookRepository; +import book.book.book.repository.ChapterRepository; +import book.book.book.service.BookSyncService; +import book.book.challenge.fixture.ChapterFixture; +import book.book.common.error_notification.DiscordChannelType; +import book.book.common.error_notification.SenderToDiscord; +import book.book.config.IntegrationTest; +import book.book.quiz.service.QuizGenerationAsyncService; +import book.book.search.dto.aladin.AladinSearchResponse; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; + +@IntegrationTest +class BestsellerServiceTest { + + @Autowired + private BestsellerService bestsellerService; + + @Autowired + private BestSellerRepository bestSellerRepository; + + @Autowired + private BookRepository bookRepository; + + @Autowired + private ChapterRepository chapterRepository; + + @MockBean + private BookSyncService bookSyncService; + + @MockBean + private QuizGenerationAsyncService quizGenerationAsyncService; + + @MockBean + private SenderToDiscord senderToDiscord; + + @Test + void 이미_존재하는_베스트셀러는_저장하지_않는다() { + // given + Book book = BookFixture.createWithoutId(); + bookRepository.save(book); + + BestSeller bestSeller = BestSeller.builder() + .book(book) + .retryCount(0) + .build(); + bestSellerRepository.save(bestSeller); + + AladinSearchResponse.SearchItem item = new AladinSearchResponse.SearchItem(); + given(bookSyncService.findOrElseSaveBook(any())).willReturn(book); + + // when + bestsellerService.saveBestSeller(item); + + // then + List bestSellers = bestSellerRepository.findAll(); + assertThat(bestSellers).hasSize(1); + } + + @Test + void 퀴즈_생성_성공_시_베스트셀러에서_삭제한다() { + // given + Book book = BookFixture.createWithoutId(); + bookRepository.save(book); + + Chapter chapter = ChapterFixture.builderWithoutId().book(book).build(); + chapterRepository.save(chapter); + + BestSeller bestSeller = BestSeller.builder() + .book(book) + .retryCount(0) + .build(); + bestSellerRepository.save(bestSeller); + + given(quizGenerationAsyncService.generateQuizzes(any(), anyList())) + .willReturn(CompletableFuture.completedFuture(null)); + + // when + bestsellerService.generateBestsellerQuizzes(); + + // then + List remaining = bestSellerRepository.findAll(); + assertThat(remaining).isEmpty(); + } + + @Test + void 퀴즈_생성_실패_시_재시도_카운트가_증가한다() throws InterruptedException { + // given + Book book = BookFixture.createWithoutId(); + bookRepository.save(book); + + Chapter chapter = ChapterFixture.builderWithoutId().book(book).build(); + chapterRepository.save(chapter); + + BestSeller bestSeller = BestSeller.builder() + .book(book) + .retryCount(0) + .build(); + bestSellerRepository.save(bestSeller); + + given(quizGenerationAsyncService.generateQuizzes(any(), anyList())) + .willReturn(CompletableFuture.failedFuture(new RuntimeException("API Error"))); + + // when + bestsellerService.generateBestsellerQuizzes(); + + // then + List result = bestSellerRepository.findAll(); + assertThat(result).hasSize(1); + assertThat(result.get(0).getRetryCount()).isEqualTo(1); + } + + @Test + void 최대_재시도_횟수_초과_시_삭제하고_알림을_보낸다() { + // given + Book book = BookFixture.createWithoutId(); + bookRepository.save(book); + + BestSeller bestSeller = BestSeller.builder() + .book(book) + .retryCount(3) // MAX_RETRY_COUNT + .build(); + bestSellerRepository.save(bestSeller); + + // when + bestsellerService.generateBestsellerQuizzes(); + + // then + List remaining = bestSellerRepository.findAll(); + assertThat(remaining).isEmpty(); + + verify(senderToDiscord, times(1)).sendLog( + any(DiscordChannelType.class), + any(String.class), + any(String.class)); + } +} From 85722714037df9a2d824cb7eec60b7227cfa9ea7 Mon Sep 17 00:00:00 2001 From: sunwon Date: Fri, 12 Dec 2025 14:54:48 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[Quiz][Refactor]=20=ED=80=B4=EC=A6=88=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=B3=80=EA=B2=BD=20=EB=A9=94=EC=86=8C?= =?UTF-8?q?=EB=93=9C=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=A0=84=EB=8B=AC=20?= =?UTF-8?q?->=20id=20=EC=A0=84=EB=8B=AC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용하는 쪽에서 deteched 상태로 사용할 수 있으므로 방지 --- .../service/QuizGenerationAsyncService.java | 29 ++++++++++--------- .../quiz/service/QuizGenerationService.java | 2 +- .../book/quiz/service/QuizSaveService.java | 13 +++++---- .../QuizGenerationAsyncServiceTest.java | 14 +++++---- .../service/QuizGenerationServiceTest.java | 4 +-- .../quiz/service/QuizSaveServiceTest.java | 16 ++++++---- 6 files changed, 44 insertions(+), 34 deletions(-) diff --git a/src/main/java/book/book/quiz/service/QuizGenerationAsyncService.java b/src/main/java/book/book/quiz/service/QuizGenerationAsyncService.java index 1aca5861..d3e7198c 100644 --- a/src/main/java/book/book/quiz/service/QuizGenerationAsyncService.java +++ b/src/main/java/book/book/quiz/service/QuizGenerationAsyncService.java @@ -41,27 +41,28 @@ public CompletableFuture generateQuizzes(Book book, List chapters } private CompletableFuture generateQuizzesInternal(Book book, List chapters, Long memberId) { - quizSaveService.updateQuizStatus(book, QuizStatus.PROCESSING); + quizSaveService.updateQuizStatus(book.getId(), QuizStatus.PROCESSING); // Gemini 비동기 API 호출 return geminiSdkClient.generateBatchQuizzesAsync( book.getTitle(), book.getAuthor(), chapters).thenAccept(batchResponse -> { - // 퀴즈 저장 및 상태 업데이트 (트랜잭션 분리) - quizSaveService.saveQuizzesBatchAndUpdateStatus(book, chapters, batchResponse); + // 퀴즈 저장 및 상태 업데이트 (트랜잭션 분리) + List chapterIds = chapters.stream().map(Chapter::getId).toList(); + quizSaveService.saveQuizzesBatchAndUpdateStatus(book.getId(), chapterIds, batchResponse); - // 퀴즈 생성 완료 이벤트 발행 (memberId가 있을 경우에만) - if (memberId != null) { - eventPublisher.publishEvent(QuizCreatedEvent.of(book.getId(), memberId)); - } - }).exceptionally(e -> { - log.error("백그라운드 퀴즈 생성 실패: bookId={}, error={}", book.getId(), e.getMessage(), e); - quizSaveService.updateQuizStatus(book, QuizStatus.FAILED); + // 퀴즈 생성 완료 이벤트 발행 (memberId가 있을 경우에만) + if (memberId != null) { + eventPublisher.publishEvent(QuizCreatedEvent.of(book.getId(), memberId)); + } + }).exceptionally(e -> { + log.error("백그라운드 퀴즈 생성 실패: bookId={}, error={}", book.getId(), e.getMessage(), e); + quizSaveService.updateQuizStatus(book.getId(), QuizStatus.FAILED); - // 디스코드 알림 전송 - quizAlertService.notifyQuizGenerationFailure(book.getId(), book.getTitle(), e); - return null; - }); + // 디스코드 알림 전송 + quizAlertService.notifyQuizGenerationFailure(book.getId(), book.getTitle(), e); + return null; + }); } } diff --git a/src/main/java/book/book/quiz/service/QuizGenerationService.java b/src/main/java/book/book/quiz/service/QuizGenerationService.java index f48fa9a8..2da13085 100644 --- a/src/main/java/book/book/quiz/service/QuizGenerationService.java +++ b/src/main/java/book/book/quiz/service/QuizGenerationService.java @@ -42,7 +42,7 @@ public QuizGenerationAcceptedResponse requestQuizGeneration(Long bookId, Long me return QuizGenerationAcceptedResponse.processing(bookId, chapters.size()); } - quizSaveService.updateQuizStatus(book, QuizStatus.PENDING); + quizSaveService.updateQuizStatus(book.getId(), QuizStatus.PENDING); // 백그라운드에서 비동기 처리 시작 asyncService.generateQuizzes(book, chapters, memberId); diff --git a/src/main/java/book/book/quiz/service/QuizSaveService.java b/src/main/java/book/book/quiz/service/QuizSaveService.java index b98d9ef0..6b02939d 100644 --- a/src/main/java/book/book/quiz/service/QuizSaveService.java +++ b/src/main/java/book/book/quiz/service/QuizSaveService.java @@ -41,8 +41,12 @@ public class QuizSaveService { * 중간 챕터에 퀴즈가 없으면 에러를 발생시킵니다. */ @Transactional - public List saveQuizzesBatchAndUpdateStatus(Book book, List chapters, + public List saveQuizzesBatchAndUpdateStatus(Long bookId, List chapterIds, GeminiQuizResponses batchResponse) { + Book book = bookRepository.findByIdOrElseThrow(bookId); + + List chapters = chapterRepository.findAllById(chapterIds); + List quizResponses = batchResponse.getQuizzes(); log.info("퀴즈 매칭 시작: book={}, 챕터 수={}, 퀴즈 응답 수={}", @@ -130,11 +134,8 @@ private void checkAndLogMissingMiddleChapters(List allChapters, List savedQuizzes = quizSaveService.saveQuizzesBatchAndUpdateStatus(book, chapters, + List savedQuizzes = quizSaveService.saveQuizzesBatchAndUpdateStatus(book.getId(), + chapters.stream().map(Chapter::getId).toList(), batchResponse); // then: 퀴즈가 정상적으로 저장됨 assertThat(savedQuizzes).hasSize(2); - assertThat(book.getQuizStatus()).isEqualTo(QuizStatus.COMPLETED); + + Book updatedBook = bookRepository.findById(book.getId()).orElseThrow(); + assertThat(updatedBook.getQuizStatus()).isEqualTo(QuizStatus.COMPLETED); Quiz quiz1 = savedQuizzes.get(0); assertThat(quiz1.getId()).isNotNull(); @@ -94,7 +97,8 @@ void setUp() { .createWithChapters(List.of(chapter)); // when: 퀴즈 저장 및 상태 업데이트 후 DB 조회 - List savedQuizzes = quizSaveService.saveQuizzesBatchAndUpdateStatus(book, List.of(chapter), + List savedQuizzes = quizSaveService.saveQuizzesBatchAndUpdateStatus(book.getId(), + List.of(chapter.getId()), batchResponse); Quiz savedQuiz = savedQuizzes.get(0); @@ -125,7 +129,8 @@ void setUp() { GeminiQuizResponses batchResponse = GeminiQuizResponsesFixture.createWithChapters(chapters); // when: 5개의 퀴즈 한 번에 저장 및 상태 업데이트 - List savedQuizzes = quizSaveService.saveQuizzesBatchAndUpdateStatus(book, chapters, + List savedQuizzes = quizSaveService.saveQuizzesBatchAndUpdateStatus(book.getId(), + chapters.stream().map(Chapter::getId).toList(), batchResponse); // then: 5개 모두 저장됨 @@ -150,7 +155,8 @@ void setUp() { .createWithChapters(List.of(chapter)); // when - List savedQuizzes = quizSaveService.saveQuizzesBatchAndUpdateStatus(book, List.of(chapter), + List savedQuizzes = quizSaveService.saveQuizzesBatchAndUpdateStatus(book.getId(), + List.of(chapter.getId()), batchResponse); Quiz savedQuiz = savedQuizzes.get(0); From 1e157bb7b5295c03afbd9e0a9cb200f824e12a76 Mon Sep 17 00:00:00 2001 From: sunwon Date: Sun, 14 Dec 2025 14:59:36 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[Quiz][Test]=20=EB=B9=84=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=A9=94=EC=86=8C=EB=93=9C=20mock=EC=97=90?= =?UTF-8?q?=EC=84=9C=20AOP=20=EA=B8=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 비동기화는 다른 스레드 사용하므로 Matcher 객체가 담긴 현재 스레드의 ThreadLocal를 인식하지 못함 --- .../service/BestsellerServiceTest.java | 40 ++++++++++--------- .../book/config/TestExternalApiConfig.java | 17 +++++++- .../book/quiz/api/QuizControllerTest.java | 3 +- 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/test/java/book/book/bestseller/service/BestsellerServiceTest.java b/src/test/java/book/book/bestseller/service/BestsellerServiceTest.java index 6cc4ba64..d818a62a 100644 --- a/src/test/java/book/book/bestseller/service/BestsellerServiceTest.java +++ b/src/test/java/book/book/bestseller/service/BestsellerServiceTest.java @@ -1,6 +1,8 @@ package book.book.bestseller.service; +import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.BDDMockito.given; @@ -14,7 +16,6 @@ import book.book.book.fixture.BookFixture; import book.book.book.repository.BookRepository; import book.book.book.repository.ChapterRepository; -import book.book.book.service.BookSyncService; import book.book.challenge.fixture.ChapterFixture; import book.book.common.error_notification.DiscordChannelType; import book.book.common.error_notification.SenderToDiscord; @@ -25,7 +26,7 @@ import java.util.concurrent.CompletableFuture; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.util.AopTestUtils; @IntegrationTest class BestsellerServiceTest { @@ -42,19 +43,16 @@ class BestsellerServiceTest { @Autowired private ChapterRepository chapterRepository; - @MockBean - private BookSyncService bookSyncService; - - @MockBean + @Autowired private QuizGenerationAsyncService quizGenerationAsyncService; - @MockBean + @Autowired private SenderToDiscord senderToDiscord; @Test void 이미_존재하는_베스트셀러는_저장하지_않는다() { // given - Book book = BookFixture.createWithoutId(); + Book book = BookFixture.builderWithoutId().aladingBookId(12345).build(); bookRepository.save(book); BestSeller bestSeller = BestSeller.builder() @@ -64,7 +62,7 @@ class BestsellerServiceTest { bestSellerRepository.save(bestSeller); AladinSearchResponse.SearchItem item = new AladinSearchResponse.SearchItem(); - given(bookSyncService.findOrElseSaveBook(any())).willReturn(book); + item.setItemId(12345); // when bestsellerService.saveBestSeller(item); @@ -77,7 +75,7 @@ class BestsellerServiceTest { @Test void 퀴즈_생성_성공_시_베스트셀러에서_삭제한다() { // given - Book book = BookFixture.createWithoutId(); + Book book = BookFixture.builderWithoutId().aladingBookId(12345).build(); bookRepository.save(book); Chapter chapter = ChapterFixture.builderWithoutId().book(book).build(); @@ -89,19 +87,22 @@ class BestsellerServiceTest { .build(); bestSellerRepository.save(bestSeller); - given(quizGenerationAsyncService.generateQuizzes(any(), anyList())) + QuizGenerationAsyncService mockService = AopTestUtils.getUltimateTargetObject(quizGenerationAsyncService); + given(mockService.generateQuizzes(any(Book.class), anyList())) .willReturn(CompletableFuture.completedFuture(null)); // when bestsellerService.generateBestsellerQuizzes(); // then - List remaining = bestSellerRepository.findAll(); - assertThat(remaining).isEmpty(); + await().atMost(2, SECONDS).untilAsserted(() -> { + List remaining = bestSellerRepository.findAll(); + assertThat(remaining).isEmpty(); + }); } @Test - void 퀴즈_생성_실패_시_재시도_카운트가_증가한다() throws InterruptedException { + void 퀴즈_생성_실패_시_재시도_카운트가_증가한다() { // given Book book = BookFixture.createWithoutId(); bookRepository.save(book); @@ -115,16 +116,19 @@ class BestsellerServiceTest { .build(); bestSellerRepository.save(bestSeller); - given(quizGenerationAsyncService.generateQuizzes(any(), anyList())) + QuizGenerationAsyncService mockService = AopTestUtils.getUltimateTargetObject(quizGenerationAsyncService); + given(mockService.generateQuizzes(any(Book.class), anyList())) .willReturn(CompletableFuture.failedFuture(new RuntimeException("API Error"))); // when bestsellerService.generateBestsellerQuizzes(); // then - List result = bestSellerRepository.findAll(); - assertThat(result).hasSize(1); - assertThat(result.get(0).getRetryCount()).isEqualTo(1); + await().atMost(2, SECONDS).untilAsserted(() -> { + List result = bestSellerRepository.findAll(); + assertThat(result).hasSize(1); + assertThat(result.get(0).getRetryCount()).isEqualTo(1); + }); } @Test diff --git a/src/test/java/book/book/config/TestExternalApiConfig.java b/src/test/java/book/book/config/TestExternalApiConfig.java index e1a3c0be..3cdf45b2 100644 --- a/src/test/java/book/book/config/TestExternalApiConfig.java +++ b/src/test/java/book/book/config/TestExternalApiConfig.java @@ -2,10 +2,12 @@ import static org.mockito.Mockito.mock; +import book.book.common.error_notification.SenderToDiscord; import book.book.crawler.service.AladinCrawlerService; import book.book.notification.external.FirebaseClient; import book.book.quiz.external.GeminiSdkClient; import book.book.quiz.service.QuizAlertService; +import book.book.quiz.service.QuizGenerationAsyncService; import book.book.search.service.AladinService; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; @@ -90,7 +92,18 @@ public FirebaseClient firebaseClient() { */ @Bean @Primary - public book.book.common.error_notification.SenderToDiscord senderToDiscord() { - return mock(book.book.common.error_notification.SenderToDiscord.class); + public SenderToDiscord senderToDiscord() { + return mock(SenderToDiscord.class); + } + + /** + * 퀴즈 생성 비동기 서비스를 Fake 객체로 대체 + * - 실제 Gemini API 호출 및 비동기 로직 방지 + * - 항상 성공하는 Future 반환 + */ + @Bean + @Primary + public QuizGenerationAsyncService quizGenerationAsyncService() { + return mock(QuizGenerationAsyncService.class); } } diff --git a/src/test/java/book/book/quiz/api/QuizControllerTest.java b/src/test/java/book/book/quiz/api/QuizControllerTest.java index 41eb5847..27556da6 100644 --- a/src/test/java/book/book/quiz/api/QuizControllerTest.java +++ b/src/test/java/book/book/quiz/api/QuizControllerTest.java @@ -25,6 +25,7 @@ import book.book.challenge.repository.V2ReadingProgressRepository; import book.book.challenge.service.V2ChallengeService; import book.book.common.error_notification.DiscordChannelType; +import book.book.common.error_notification.SenderToDiscord; import book.book.config.IntegrationTest; import book.book.config.SecurityTestUtils; import book.book.member.entity.Member; @@ -95,7 +96,7 @@ class QuizControllerTest { private V2ChallengeService v2ChallengeService; @Autowired - private book.book.common.error_notification.SenderToDiscord senderToDiscord; + private SenderToDiscord senderToDiscord; private Member member; private Book book; From 9869ae90f515258a82d56f3ea49cb8db3d903877 Mon Sep 17 00:00:00 2001 From: sunwon Date: Sun, 14 Dec 2025 15:09:34 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[Challenge][Fix]=20=ED=95=9C=20=EB=AA=A9?= =?UTF-8?q?=EC=B0=A8=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=A7=84=ED=96=89?= =?UTF-8?q?=EC=83=81=ED=99=A9=20=EC=97=94=ED=8B=B0=ED=8B=B0=EB=A5=BC=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EB=A7=8C=20=EC=83=9D=EC=84=B1=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V2ReadingProgressRepository.java | 5 ++- .../service/ReadingProgressService.java | 39 ++++++++++++++----- .../dto/response/V2ChallengeResponse.java | 3 +- .../service/ReadingProgressServiceTest.java | 13 +++++++ 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/main/java/book/book/challenge/repository/V2ReadingProgressRepository.java b/src/main/java/book/book/challenge/repository/V2ReadingProgressRepository.java index 9d59a3d8..b3f0cb08 100644 --- a/src/main/java/book/book/challenge/repository/V2ReadingProgressRepository.java +++ b/src/main/java/book/book/challenge/repository/V2ReadingProgressRepository.java @@ -27,8 +27,11 @@ public interface V2ReadingProgressRepository extends JpaRepository findAllByReadingChallengeAndChapterId(ReadingChallenge challenge, + Long chapterId); + Optional findByReadingChallengeAndChapterId(ReadingChallenge challenge, - Long chapterId); + Long chapterId); default V2ReadingProgress findByIdOrElseThrow(Long id) { return findById(id) diff --git a/src/main/java/book/book/challenge/service/ReadingProgressService.java b/src/main/java/book/book/challenge/service/ReadingProgressService.java index f5610989..8e863673 100644 --- a/src/main/java/book/book/challenge/service/ReadingProgressService.java +++ b/src/main/java/book/book/challenge/service/ReadingProgressService.java @@ -11,11 +11,14 @@ import book.book.common.ErrorCode; import book.book.common.lock.DistributedLock; import book.book.member.repository.MemberRepository; +import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor public class ReadingProgressService { @@ -31,13 +34,17 @@ public Long addProgress(Long memberId, Long challengeId, Long chapterId) { ReadingChallenge challenge = challengeRepository.findByIdOrElseThrow(challengeId); Chapter chapter = chapterRepository.findByIdOrElseThrow(chapterId); - V2ReadingProgress progress = V2ReadingProgress.builder() - .readingChallenge(challenge) - .chapterId(chapter.getId()) - .status(ChapterStatus.PROCESSING) - .build(); - - return readingProgressRepository.save(progress).getId(); + // 멱등성 보장: 이미 존재하는 진행 상태가 있다면 새로 생성하지 않고 기존 ID 반환 + return readingProgressRepository.findByReadingChallengeAndChapterId(challenge, chapter.getId()) + .map(V2ReadingProgress::getId) + .orElseGet(() -> { + V2ReadingProgress progress = V2ReadingProgress.builder() + .readingChallenge(challenge) + .chapterId(chapter.getId()) + .status(ChapterStatus.PROCESSING) + .build(); + return readingProgressRepository.save(progress).getId(); + }); } @DistributedLock(key = "'progress_lock:' + #memberId + ':' + #chapterId") @@ -48,8 +55,22 @@ public boolean completeChapter(Long memberId, Long chapterId) { memberRepository.findByIdOrElseThrow(memberId), chapter.getBook()); - V2ReadingProgress progress = readingProgressRepository.findByReadingChallengeAndChapterId(challenge, chapterId) - .orElseThrow(() -> new CustomException(ErrorCode.PROGRESS_NOT_FOUND)); + List progresses = readingProgressRepository.findAllByReadingChallengeAndChapterId( + challenge, chapterId); + + if (progresses.isEmpty()) { + throw new CustomException(ErrorCode.PROGRESS_NOT_FOUND); + } + + // 중복 데이터 처리: 첫 번째 데이터만 남기고 나머지는 삭제 + V2ReadingProgress progress = progresses.get(0); + if (progresses.size() > 1) { + log.warn("중복된 진행 상태 발견 및 정리: challengeId={}, chapterId={}, count={}", + challenge.getId(), chapterId, progresses.size()); + for (int i = 1; i < progresses.size(); i++) { + readingProgressRepository.delete(progresses.get(i)); + } + } progress.updateStatus(ChapterStatus.COMPLETED); diff --git a/src/main/java/book/book/quiz/dto/response/V2ChallengeResponse.java b/src/main/java/book/book/quiz/dto/response/V2ChallengeResponse.java index 4b2ba197..bd2941a1 100644 --- a/src/main/java/book/book/quiz/dto/response/V2ChallengeResponse.java +++ b/src/main/java/book/book/quiz/dto/response/V2ChallengeResponse.java @@ -122,7 +122,8 @@ public static V2ChallengeResponse.Detail of(List chapters, List progressMap = progresses.stream() .collect(Collectors.toMap(V2ReadingProgress::getChapterId, - V2ReadingProgress::getStatus)); + V2ReadingProgress::getStatus, + (existing, replacement) -> existing)); List chapterProgresses = chapters.stream() .map(chapter -> { diff --git a/src/test/java/book/book/challenge/service/ReadingProgressServiceTest.java b/src/test/java/book/book/challenge/service/ReadingProgressServiceTest.java index e1dbdb0d..e7f20828 100644 --- a/src/test/java/book/book/challenge/service/ReadingProgressServiceTest.java +++ b/src/test/java/book/book/challenge/service/ReadingProgressServiceTest.java @@ -87,6 +87,19 @@ void setUp() { assertThat(progress.getStatus()).isEqualTo(ChapterStatus.PROCESSING); } + @Test + void 진행_상태_추가_시_중복_생성을_방지한다() { + // given + Long firstId = readingProgressService.addProgress(member.getId(), challenge.getId(), chapter.getId()); + + // when + Long secondId = readingProgressService.addProgress(member.getId(), challenge.getId(), chapter.getId()); + + // then + assertThat(firstId).isEqualTo(secondId); + assertThat(readingProgressRepository.countByReadingChallenge(challenge)).isEqualTo(1); + } + @Test void 챕터를_완료하면_상태가_COMPLETED로_변경된다() { // given From 02276e9723ffa5c638ee61c5546d5f0d1391de59 Mon Sep 17 00:00:00 2001 From: sunwon Date: Sun, 14 Dec 2025 15:21:27 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[Challenge][Fix]=20=EC=A7=84=ED=96=89?= =?UTF-8?q?=EC=83=81=ED=99=A9=20=EC=97=94=ED=8B=B0=ED=8B=B0=EC=97=90=20?= =?UTF-8?q?=EC=9C=A0=EB=8B=88=ED=81=AC=20=EC=A0=9C=EC=95=BD=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book/book/challenge/domain/V2ReadingProgress.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/book/book/challenge/domain/V2ReadingProgress.java b/src/main/java/book/book/challenge/domain/V2ReadingProgress.java index da1e1a28..af7cda12 100644 --- a/src/main/java/book/book/challenge/domain/V2ReadingProgress.java +++ b/src/main/java/book/book/challenge/domain/V2ReadingProgress.java @@ -1,15 +1,18 @@ package book.book.challenge.domain; import book.book.common.BaseTimeEntity; +import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.Enumerated; import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; 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 jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -21,6 +24,9 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "v2_reading_progress", uniqueConstraints = { + @UniqueConstraint(name = "uk_challenge_chapter", columnNames = { "reading_challenge_id", "chapter_id" }) +}) public class V2ReadingProgress extends BaseTimeEntity { @Id @@ -31,6 +37,7 @@ public class V2ReadingProgress extends BaseTimeEntity { @JoinColumn(name = "reading_challenge_id") private ReadingChallenge readingChallenge; + @Column(name = "chapter_id") private Long chapterId; @Enumerated(EnumType.STRING)