Skip to content

[Quiz][Feat] 베스트 셀러 퀴즈 생성 스케줄러#198

Merged
sunwon12 merged 5 commits intodevfrom
feat/197-bestseller
Dec 14, 2025
Merged

[Quiz][Feat] 베스트 셀러 퀴즈 생성 스케줄러#198
sunwon12 merged 5 commits intodevfrom
feat/197-bestseller

Conversation

@sunwon12
Copy link
Contributor

@sunwon12 sunwon12 commented Dec 14, 2025

배경

  • 기능 추가: 베스트셀러 도서에 대한 퀴즈를 주기적으로 자동 생성하는 기능이 필요했습니다.
  • 서버 에러 발생: 운영 환경에서 Duplicate key (Challenge API) 및 NonUniqueResultException (Quiz Submission API) 에러가 빈번하게 발생했습니다. 이는 챕터 진행 상태(V2ReadingProgress)가 중복으로 생성되어 발생한 문제입니다.
  • 테스트 실패: @async 메서드를 Mockito로 테스트할 때 InvalidUseOfMatchersException이 발생하여 CI 안정성을 저해했습니다.
    해결 목표

핵심 개선점

1. 베스트셀러 퀴즈 생성 스케줄러 구현

[Feat]
BestsellerScheduler
:
수집: 매주 월요일 새벽 2시에 알라딘 베스트셀러(1~20페이지, 약 1000권)를 수집하여 DB에 저장합니다.
생성: 매주 월요일 오전 3시 ~ 오후 11시 사이에 매 시각 정각마다 수집된 베스트셀러에 대한 퀴즈 생성을 요청합니다.
[Refactor] QuizSaveService: 엔티티 대신 ID를 파라미터로 받도록 리팩토링하여 Detached Entity 문제를 방지했습니다.

사용자 경험 개선: 베스트셀러 도서에 대해 미리 퀴즈를 생성해 둠으로써, 사용자가 챌린지를 생성할 때 약 1분의 대기 시간 없이 즉시 이용할 수 있게 됩니다.
부하 분산: 퀴즈 생성 요청을 월요일 하루 동안 분산시켜 Gemini API 및 서버 부하를 조절합니다.

2. 챌린지 진행 상태 중복 생성 방지 및 처리

[Fix] ReadingProgressService.addProgress: findFirstBy...를 사용하여 이미 진행 상태가 존재할 경우 새로 생성하지 않고 기존 ID를 반환하도록 수정했습니다 (멱등성 보장).
[Fix] ReadingProgressService.completeChapter: 챕터 완료 처리 시 중복된 진행 상태가 발견되면, 하나만 남기고 나머지는 삭제(Deduplication)하도록 로직을 개선했습니다.
[Fix]
V2ChallengeResponse
: toMap 수집 시 중복 키가 있어도 에러가 나지 않도록 Merge Function을 추가했습니다.

3. 비동기 메서드 테스트 수정

[Test]
BestsellerServiceTest
: @async가 적용된
QuizGenerationAsyncService
의 Mocking 실패 문제를 해결하기 위해 AopTestUtils.getUltimateTargetObject()를 사용하여 프록시를 벗겨낸 후 Stubbing 하도록 수정했습니다.

@async 프록시로 인해 Mockito의 ThreadLocal Matcher가 인식되지 않는 근본 원인을 해결하기 위해, 프록시 타겟을 직접 꺼내서 Stubbing 하는 방식을 선택했습니다.

- 사용하는 쪽에서 deteched 상태로 사용할 수 있으므로 방지
- 비동기화는 다른 스레드 사용하므로 Matcher 객체가 담긴 현재 스레드의 ThreadLocal를 인식하지 못함
@sunwon12 sunwon12 requested a review from AlphaBs December 14, 2025 06:11
@sunwon12 sunwon12 self-assigned this Dec 14, 2025
@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @sunwon12, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

이 PR은 베스트셀러 책에 대한 퀴즈를 미리 생성하여 사용자 경험을 개선하고, 퀴즈 생성 시스템의 안정성을 높이는 기능을 도입합니다. 또한, 기존 독서 진행 상황 관리 로직의 데이터 정합성을 강화하고, 퀴즈 생성 관련 서비스의 영속성 컨텍스트 관리 방식을 개선하여 코드의 견고함을 향상시켰습니다.

Highlights

  • 베스트셀러 퀴즈 생성 스케줄러 도입: 알라딘 베스트셀러 목록을 주기적으로 수집하고, 수집된 베스트셀러 책에 대한 퀴즈를 미리 생성하는 스케줄러가 추가되었습니다. 이는 사용자가 챌린지를 생성할 때 퀴즈 생성 대기 시간을 줄여 사용자 경험을 개선합니다. 퀴즈 생성 실패 시 재시도 로직이 포함되며, 3회 실패 시 Discord를 통해 알림이 전송됩니다.
  • 퀴즈 생성 로직 개선 및 안정성 강화: 퀴즈 생성 및 저장 관련 서비스(QuizGenerationAsyncService, QuizGenerationService, QuizSaveService)에서 Book 엔티티 대신 bookIdchapterIds를 직접 사용하여 영속성 컨텍스트 관리의 복잡성을 줄이고 비동기 처리 시 발생할 수 있는 잠재적 문제를 방지하도록 로직이 개선되었습니다.
  • 독서 진행 상황(ReadingProgress) 데이터 정합성 및 멱등성 강화: ReadingProgressServiceaddProgress 메서드는 이미 진행 상태가 존재할 경우 중복 생성하지 않고 기존 ID를 반환하여 멱등성을 보장합니다. 또한, completeChapter 메서드에서 중복된 V2ReadingProgress 데이터가 발견될 경우, 첫 번째 데이터만 유효하게 유지하고 나머지는 삭제하여 데이터 정합성을 강화했습니다.
  • 크롤러 오류 Discord 알림 채널 추가: 크롤러 관련 오류 발생 시 별도의 Discord 채널을 통해 알림을 받을 수 있도록 CRAWLER_ERROR Discord 채널 타입이 추가되었고, 관련 설정이 반영되었습니다.
  • 테스트 코드 추가 및 개선: BestsellerService에 대한 통합 테스트가 추가되어 핵심 로직의 동작을 검증합니다. 또한, ReadingProgressServiceTest에 진행 상태 중복 생성 방지 테스트 케이스가 추가되었으며, 퀴즈 관련 서비스 테스트 코드들이 변경된 로직에 맞춰 업데이트되었습니다. 테스트 환경에서 실제 비동기 퀴즈 생성 로직이 실행되지 않도록 QuizGenerationAsyncService를 Mocking하는 설정도 추가되었습니다.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

이 PR은 베스트셀러 도서에 대한 퀴즈를 미리 생성하는 스케줄러 기능을 추가하는 것을 중심으로 합니다. 전반적으로 새로운 기능의 구현과 테스트 코드 작성이 잘 이루어졌습니다. 특히 비동기 처리와 재시도 로직, 오류 알림 등 안정성을 고려한 설계가 돋보입니다. 또한, 기존 퀴즈 생성 관련 서비스에서 엔티티 대신 ID를 사용하도록 리팩토링하여 코드의 결합도를 낮춘 점도 좋은 개선입니다. 몇 가지 스케줄러 설정의 명확성, 엣지 케이스 처리, DB操作의 원자성 보장에 대한 개선점을 제안했습니다.

Comment on lines +64 to +66
if (chapters.isEmpty()) {
return;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

processBestseller 메서드에서 챕터가 없는 책(chapters.isEmpty())의 경우 아무런 처리 없이 return하고 있습니다. 이 경우 해당 BestSeller 엔티티는 DB에서 삭제되지 않고, 스케줄러에 의해 매시간 다시 처리되어 불필요한 부하를 유발할 수 있습니다. 챕터가 없어 퀴즈 생성이 불가능한 책은 BestSeller 테이블에서 삭제하여 더 이상 처리되지 않도록 하는 것이 좋겠습니다.

        if (chapters.isEmpty()) {
            log.warn("책에 챕터가 없어 퀴즈를 생성할 수 없습니다. 베스트셀러 목록에서 삭제합니다. bookId: {}", book.getId());
            bestSellerRepository.delete(bestSeller);
            return;
        }

Comment on lines +25 to +27
* 매주 일요일 밤 10시에 알라딘 베스트셀러를 가져와 저장
*/
@Scheduled(cron = "0 0 2 * * MON")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

스케줄러의 cron 표현식과 주석의 내용이 일치하지 않습니다. 주석에는 "매주 일요일 밤 10시"라고 되어 있지만, cron 표현식 0 0 2 * * MON은 "매주 월요일 새벽 2시"를 의미합니다. 의도한 스케줄 시간에 맞게 주석이나 cron 표현식을 수정하여 혼동을 줄이는 것이 좋겠습니다. 참고로 일요일 밤 10시는 0 0 22 * * SUN 입니다. 뒤따르는 퀴즈 생성 스케줄이 월요일 새벽 3시부터인 것을 감안할 때, 주석을 수정하는 것이 더 적절해 보입니다.

    /**
     * 매주 월요일 새벽 2시에 알라딘 베스트셀러를 가져와 저장
     */
    @Scheduled(cron = "0 0 2 * * MON")

Comment on lines +91 to +100
private void handleFailure(Long bestSellerId) {
try {
bestSellerRepository.findById(bestSellerId).ifPresent(bestSeller -> {
bestSeller.incrementRetryCount();
bestSellerRepository.save(bestSeller);
});
} catch (Exception ex) {
log.error("실패 카운트 증가 중 오류 발생", ex);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

현재 handleFailure 메서드는 findById로 엔티티를 조회하고 save로 상태를 업데이트하는 두 단계로 이루어져 있습니다. 이 작업은 원자적이지 않아 드물게 경쟁 상태(race condition)를 유발할 수 있습니다.

더 안전하고 효율적인 방법은 BestSellerRepository@Modifying 어노테이션을 사용한 업데이트 쿼리 메서드를 추가하여 DB에서 직접 값을 증가시키는 것입니다. 이렇게 하면 단일 DB 호출로 작업을 완료할 수 있습니다.

BestSellerRepository.java에 추가할 내용:

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);

위 메서드를 추가한 후 handleFailure를 다음과 같이 수정하는 것을 권장합니다.

    private void handleFailure(Long bestSellerId) {
        try {
            bestSellerRepository.incrementRetryCount(bestSellerId);
        } catch (Exception ex) {
            log.error("실패 카운트 증가 중 오류 발생: bestSellerId={}", bestSellerId, ex);
        }
    }

@github-actions
Copy link

Test Results

101 files  101 suites   18s ⏱️
530 tests 530 ✅ 0 💤 0 ❌
540 runs  540 ✅ 0 💤 0 ❌

Results for commit 9869ae9.

@github-actions
Copy link

github-actions bot commented Dec 14, 2025

🌻 테스트 커버리지 리포트

Overall Project 51.41% -0.48% 🍏
Files changed 67.56% 🍏

File Coverage
BestsellerService.java 85.87% -14.13% 🍏
BestSeller.java 83.16% -16.84% 🍏
V2ReadingProgress.java 76.47% 🍏
ReadingProgressService.java 75.61% -24.39% 🍏
V2ReadingProgressRepository.java 58.33% 🍏
BestsellerScheduler.java 16.88% -83.12%

@sunwon12 sunwon12 merged commit a2366d3 into dev Dec 14, 2025
1 check passed
@sunwon12 sunwon12 deleted the feat/197-bestseller branch December 14, 2025 06:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant