Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/main/java/book/book/bestseller/entity/BestSeller.java
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")
Comment on lines +25 to +27
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")

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();
}
}
101 changes: 101 additions & 0 deletions src/main/java/book/book/bestseller/service/BestsellerService.java
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
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;
        }


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

}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ public interface V2ReadingProgressRepository extends JpaRepository<V2ReadingProg

long countByReadingChallengeAndStatus(ReadingChallenge challenge, ChapterStatus status);

List<V2ReadingProgress> findAllByReadingChallengeAndChapterId(ReadingChallenge challenge,
Long chapterId);

Optional<V2ReadingProgress> findByReadingChallengeAndChapterId(ReadingChallenge challenge,
Long chapterId);
Long chapterId);

default V2ReadingProgress findByIdOrElseThrow(Long id) {
return findById(id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")
Expand All @@ -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<V2ReadingProgress> 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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public enum DiscordChannelType {

ERROR_NOTIFICATION("에러 알림"),
GEMINI_API("제미나이 API"),
QUIZ_FEEDBACK("퀴즈 오류 신고");
QUIZ_FEEDBACK("퀴즈 오류 신고"),
CRAWLER_ERROR("크롤러 에러");

private final String description;
}
4 changes: 4 additions & 0 deletions src/main/java/book/book/config/DiscordWebhookConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ public static V2ChallengeResponse.Detail of(List<Chapter> chapters, List<V2Readi
BookOverviewResponse bookOverview) {
Map<Long, ChapterStatus> progressMap = progresses.stream()
.collect(Collectors.toMap(V2ReadingProgress::getChapterId,
V2ReadingProgress::getStatus));
V2ReadingProgress::getStatus,
(existing, replacement) -> existing));

List<ChapterProgress> chapterProgresses = chapters.stream()
.map(chapter -> {
Expand Down
Loading
Loading