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
36 changes: 36 additions & 0 deletions src/main/java/book/book/quiz/domain/QuizSubscriber.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package book.book.quiz.domain;

import book.book.common.BaseTimeEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "quiz_subscriber", uniqueConstraints = {
@UniqueConstraint(name = "uk_quiz_subscriber_book_member", columnNames = {"book_id", "member_id"})
})
public class QuizSubscriber extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private Long bookId;

private Long memberId;

@Builder
public QuizSubscriber(Long bookId, Long memberId) {
this.bookId = bookId;
this.memberId = memberId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package book.book.quiz.repository;

import book.book.quiz.domain.QuizSubscriber;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;

public interface QuizSubscriberRepository extends JpaRepository<QuizSubscriber, Long> {

List<QuizSubscriber> findAllByBookId(Long bookId);

@Modifying
@Query("DELETE FROM QuizSubscriber qs WHERE qs.bookId = :bookId")
void deleteAllByBookId(Long bookId);

boolean existsByBookIdAndMemberId(Long bookId, Long memberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,15 @@

import book.book.book.entity.Book;
import book.book.book.entity.Chapter;
import book.book.common.CustomException;
import book.book.common.ErrorCode;
import book.book.quiz.domain.QuizStatus;
import book.book.quiz.dto.external.GeminiQuizResponses;
import book.book.quiz.dto.external.GeminiRequest;
import book.book.quiz.event.QuizCreatedEvent;
import book.book.quiz.external.gemini.GeminiRequestFactory;
import book.book.quiz.external.gemini.GeminiSdkClient;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

Expand All @@ -32,6 +27,7 @@ public class QuizGenerationAsyncService {
private final QuizSaveService quizSaveService;
private final GeminiRequestFactory geminiRequestFactory;
private final QuizGenerationHandlerService quizGenerationHandlerService;

/**
* 백그라운드에서 퀴즈 생성
*/
Expand All @@ -55,7 +51,7 @@ private CompletableFuture<Void> generateQuizzesInternal(Book book, List<Chapter>

return geminiSdkClient.generateContentAsync(request)
.thenAccept(batchResponse -> {
quizGenerationHandlerService.handleSuccess(book, chapters, batchResponse, memberId);
quizGenerationHandlerService.handleSuccess(book, chapters, batchResponse);
}).exceptionally(ex -> {
quizGenerationHandlerService.handleFailure(book, ex);
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@
import book.book.common.CustomException;
import book.book.common.ErrorCode;
import book.book.quiz.domain.QuizStatus;
import book.book.quiz.domain.QuizSubscriber;
import book.book.quiz.dto.external.GeminiQuizResponses;
import book.book.quiz.event.QuizCreatedEvent;
import book.book.quiz.repository.QuizSubscriberRepository;
import java.util.List;
import java.util.concurrent.CompletionException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
Expand All @@ -22,18 +25,26 @@ public class QuizGenerationHandlerService {
private final QuizSaveService quizSaveService;
private final ApplicationEventPublisher eventPublisher;
private final QuizAlertService quizAlertService;
private final QuizSubscriberRepository quizSubscriberRepository;

public void handleSuccess(Book book, List<Chapter> chapters, GeminiQuizResponses batchResponse, Long memberId) {
@Transactional
public void handleSuccess(Book book, List<Chapter> chapters, GeminiQuizResponses batchResponse) {
List<Long> chapterIds = chapters.stream().map(Chapter::getId).toList();

quizSaveService.saveQuizzesBatchAndUpdateStatus(
book.getId(),
chapterIds,
batchResponse);

if (memberId != null) {
eventPublisher.publishEvent(new QuizCreatedEvent(book.getId(), memberId));
sendEventToAllRequesters(book);
}

private void sendEventToAllRequesters(Book book) {
List<QuizSubscriber> subscribers = quizSubscriberRepository.findAllByBookId(book.getId());
for (QuizSubscriber subscriber : subscribers) {
eventPublisher.publishEvent(new QuizCreatedEvent(book.getId(), subscriber.getMemberId()));
}
quizSubscriberRepository.deleteAllByBookId(book.getId());
}
Comment on lines +42 to 48
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

퀴즈 생성 완료 후 모든 구독자에게 알림을 보내는 로직이 추가되었네요. 한 가지 고려할 점은 findAllByBookId가 매우 많은 수의 구독자를 반환할 경우 메모리 사용량이 급증할 수 있다는 점입니다. 현재는 구독자 수가 많지 않을 것으로 예상되지만, 향후 확장성을 고려하여 Stream을 사용한 처리를 검토해볼 수 있습니다.

예를 들어, Stream<QuizSubscriber> findAllByBookId(Long bookId)와 같이 리포지토리 메서드를 변경하고 서비스에서 스트림을 순회하며 이벤트를 발행하면 대용량 데이터 처리 시 더 안정적일 수 있습니다. @Transactional(readOnly = true)와 함께 사용하면 더 효과적입니다.


public void handleFailure(Book book, Throwable ex) {
Expand Down
19 changes: 14 additions & 5 deletions src/main/java/book/book/quiz/service/QuizGenerationService.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@
import book.book.common.CustomException;
import book.book.common.ErrorCode;
import book.book.quiz.domain.QuizStatus;
import book.book.quiz.domain.QuizSubscriber;
import book.book.quiz.dto.external.GeminiQuizResponses;
import book.book.quiz.dto.external.GeminiRequest;
import book.book.quiz.dto.response.QuizGenerationAcceptedResponse;
import book.book.quiz.external.gemini.GeminiRequestFactory;
import book.book.quiz.external.gemini.GeminiSdkClient;
import book.book.quiz.repository.QuizSubscriberRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
Expand All @@ -29,6 +30,7 @@ public class QuizGenerationService {
private final GeminiSdkClient geminiSdkClient;
private final GeminiRequestFactory geminiRequestFactory;
private final QuizGenerationHandlerService handlerService;
private final QuizSubscriberRepository quizSubscriberRepository;
private final QuizGenerationAsyncService quizGenerationAsyncService;

/**
Expand All @@ -45,7 +47,15 @@ public QuizGenerationAcceptedResponse generateQuizIfAbsent(Long bookId, Long mem
return QuizGenerationAcceptedResponse.alreadyExists(bookId, chapters.size());
}

if (book.getQuizStatus() == QuizStatus.PROCESSING) {
// 구독자 추가 (중복 방지)
if (!quizSubscriberRepository.existsByBookIdAndMemberId(bookId, memberId)) {
quizSubscriberRepository.save(QuizSubscriber.builder()
.bookId(bookId)
.memberId(memberId)
.build());
}

if (book.getQuizStatus() == QuizStatus.PROCESSING || book.getQuizStatus() == QuizStatus.PENDING) {
// 이미 생성 중이면 중복 요청으로 간주하고 현재 상태 반환 (또는 예외 처리)
// 여기서는 멱등성을 위해 성공 응답처럼 처리하되, 실제 생성은 스킵
return QuizGenerationAcceptedResponse.processing(bookId, chapters.size());
Expand All @@ -63,7 +73,6 @@ public QuizGenerationAcceptedResponse generateQuizAsync(Long bookId, Long member
return QuizGenerationAcceptedResponse.of(bookId, chapters.size());
}


private List<Chapter> validateAndGetChapters(Long bookId) {
List<Chapter> chapters = chapterRepository.findByBookIdOrderByChapterNumber(bookId);
if (chapters.isEmpty()) {
Expand All @@ -76,7 +85,7 @@ private List<Chapter> validateAndGetChapters(Long bookId) {
* 동기적으로 퀴즈 생성 (스케줄러용)
*/
public void generateQuizzes(Book book, List<Chapter> chapters) {
generateQuizzesSyncInternal(book, chapters, null);
generateQuizzesSyncInternal(book, chapters, null);
}

/**
Expand All @@ -94,7 +103,7 @@ private void generateQuizzesSyncInternal(Book book, List<Chapter> chapters, Long

GeminiQuizResponses batchResponse = geminiSdkClient.generateContent(request);

handlerService.handleSuccess(book, chapters, batchResponse, memberId);
handlerService.handleSuccess(book, chapters, batchResponse);

} catch (Exception ex) {
handlerService.handleFailure(book, ex);
Expand Down

This file was deleted.

2 changes: 2 additions & 0 deletions src/test/java/book/book/config/IntegrationTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.TestExecutionListeners.MergeMode;
import org.springframework.test.context.event.RecordApplicationEvents;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
Expand All @@ -26,5 +27,6 @@
)
@ExtendWith(CleanDatabaseBeforeEach.class)
@ExtendWith(GlobalTimeZoneExtension.class)
@RecordApplicationEvents
public @interface IntegrationTest {
}
Loading
Loading