diff --git a/src/main/java/book/book/book/entity/Book.java b/src/main/java/book/book/book/entity/Book.java index 0d7059fb..a936c3ed 100644 --- a/src/main/java/book/book/book/entity/Book.java +++ b/src/main/java/book/book/book/entity/Book.java @@ -1,14 +1,25 @@ package book.book.book.entity; import book.book.common.BaseTimeEntity; -import book.book.common.CustomException; -import book.book.common.ErrorCode; import book.book.quiz.domain.QuizStatus; -import jakarta.persistence.*; - +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +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.Lob; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import java.time.LocalDate; - -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; /** * ISBN (유일값) 기준으로 책을 찾습니다 @@ -46,7 +57,7 @@ public class Book extends BaseTimeEntity { private BookCategory category; @Builder.Default - private Integer chapterCount=0; + private Integer chapterCount = 0; @Builder.Default private Integer diaryCount = 0; @@ -73,6 +84,11 @@ public void updateQuizStatus(QuizStatus quizStatus) { this.quizStatus = quizStatus; } + public void completeQuizGeneration(int newQuizCount) { + this.generatedQuizCount = newQuizCount; + this.quizStatus = QuizStatus.COMPLETED; + } + public void updateGeneratedQuizCount(int count) { this.generatedQuizCount = count; } @@ -83,7 +99,7 @@ public void updateRating(Float aladinStarRating) { /** * 알라딘 별점(10점 만점)을 5점 만점으로 변환하여 저장합니다. - * + * * @param aladinRating 알라딘 별점 (0.0 ~ 10.0) */ public void updateRatingFromAladin(Float aladinRating) { @@ -124,12 +140,6 @@ public void updateChapterCount(Integer chapterCount) { this.chapterCount = chapterCount; } - public void validateHasChapter() { - if(this.chapterCount == null || this.chapterCount <= 0) { - throw new CustomException(ErrorCode.NO_HAS_CHAPTER); - } - } - public void plusDiaryCount() { this.diaryCount++; } diff --git a/src/main/java/book/book/common/CustomObjectMapper.java b/src/main/java/book/book/common/CustomObjectMapper.java new file mode 100644 index 00000000..11ef516e --- /dev/null +++ b/src/main/java/book/book/common/CustomObjectMapper.java @@ -0,0 +1,20 @@ +package book.book.common; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CustomObjectMapper { + + private final ObjectMapper objectMapper; + + public T readValue(String content, Class valueType) { + try { + return objectMapper.readValue(content, valueType); + } catch (Exception e) { + throw new CustomException(ErrorCode.QUIZ_GENERATION_FAILED, e); + } + } +} diff --git a/src/main/java/book/book/config/RedisConfig.java b/src/main/java/book/book/config/RedisConfig.java index a739eeac..bc5c2b0b 100644 --- a/src/main/java/book/book/config/RedisConfig.java +++ b/src/main/java/book/book/config/RedisConfig.java @@ -123,4 +123,15 @@ public RedisScript updateRankingScript() { redisScript.setResultType(Long.class); return redisScript; } + + /** + * Atomic Rate Limiter Lua Script (Daily + RPM) + */ + @Bean(name = "acquirePermitScript") + public RedisScript acquirePermitScript() { + DefaultRedisScript redisScript = new DefaultRedisScript<>(); + redisScript.setLocation(new ClassPathResource("redis/scripts/acquire_permit.lua")); + redisScript.setResultType(Long.class); + return redisScript; + } } diff --git a/src/main/java/book/book/quiz/api/QuizAdminController.java b/src/main/java/book/book/quiz/api/QuizAdminController.java index 88759143..d8507baa 100644 --- a/src/main/java/book/book/quiz/api/QuizAdminController.java +++ b/src/main/java/book/book/quiz/api/QuizAdminController.java @@ -25,8 +25,7 @@ public class QuizAdminController { private final QuizAdminService quizAdminService; private final QuizGenerationService quizGenerationService; - @Operation(summary = "책의 모든 챕터 퀴즈 일괄 생성(어드민 용)", - description = "퀴즈 생성 요청을 접수하고 즉시 응답합니다. 실제 퀴즈는 백그라운드에서 생성됩니다.") + @Operation(summary = "책의 모든 챕터 퀴즈 일괄 생성", description = "퀴즈 생성 요청을 접수하고 즉시 응답합니다. 실제 퀴즈는 백그라운드에서 생성됩니다.") @PostMapping("/books/{bookId}/quizzes/generate-all") public ResponseForm generateAllChapterQuizzes( @PathVariable Long bookId, diff --git a/src/main/java/book/book/quiz/dto/external/GeminiRequest.java b/src/main/java/book/book/quiz/dto/external/GeminiRequest.java new file mode 100644 index 00000000..79d2eb9d --- /dev/null +++ b/src/main/java/book/book/quiz/dto/external/GeminiRequest.java @@ -0,0 +1,17 @@ +package book.book.quiz.dto.external; + +import com.google.genai.types.Content; +import com.google.genai.types.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class GeminiRequest { + private final String prompt; + private final Content systemInstruction; + private final Schema schema; + private final float temperature; + private final Class responseType; + private final String contextForLogging; +} diff --git a/src/main/java/book/book/quiz/external/GeminiClientManager.java b/src/main/java/book/book/quiz/external/GeminiClientManager.java deleted file mode 100644 index 8f075429..00000000 --- a/src/main/java/book/book/quiz/external/GeminiClientManager.java +++ /dev/null @@ -1,174 +0,0 @@ -package book.book.quiz.external; - -import book.book.common.CustomException; -import book.book.common.ErrorCode; -import book.book.quiz.config.GeminiAccountConfig; -import com.google.genai.Client; -import jakarta.annotation.PostConstruct; -import java.time.Duration; -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.script.RedisScript; -import org.springframework.stereotype.Component; - -/** - * Gemini 클라이언트 관리 및 로드밸런싱 - * - 여러 Gemini 계정을 Round-Robin 방식으로 로드밸런싱 - * - Sliding Window Rate Limiter를 사용한 정확한 분당 호출 제한(RPM) 관리 - * - Redis 기반 일일 호출 제한(RPD) 관리 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class GeminiClientManager { - - private final GeminiAccountConfig accountConfig; - private final RedisTemplate redisTemplate; - - @Qualifier("rateLimiterScript") - private final RedisScript rateLimiterScript; - - @Qualifier("dailyCounterScript") - private final RedisScript dailyCounterScript; - - private List clients; - private List accountNames; - private Map rateLimiters; - private final AtomicInteger currentIndex = new AtomicInteger(0); - - private static final String REDIS_KEY_DAILY = "gemini:daily-calls:"; - - @PostConstruct - public void init() { - if (accountConfig.getAccounts() == null || accountConfig.getAccounts().isEmpty()) { - throw new CustomException(ErrorCode.GEMINI_NO_ACCOUNTS_CONFIGURED); - } - - this.clients = accountConfig.getAccounts().stream() - .map(account -> new Client.Builder() - .apiKey(account.getApiKey()) - .build()) - .toList(); - - this.accountNames = accountConfig.getAccounts().stream() - .map(GeminiAccountConfig.Account::getName) - .toList(); - - // 각 계정별 Sliding Window Rate Limiter 생성 - this.rateLimiters = new HashMap<>(); - for (String accountName : accountNames) { - rateLimiters.put(accountName, new SlidingWindowRateLimiter( - redisTemplate, - rateLimiterScript, - accountConfig.getRequestsPerMinute(), - Duration.ofMinutes(1), - accountName - )); - } - - log.info("Gemini 클라이언트 초기화 완료: 총 {}개 계정, RPM: {}, 일일 한도: {}", - clients.size(), accountConfig.getRequestsPerMinute(), accountConfig.getMaxCallsPerDay()); - } - - /** - * 사용 가능한 Gemini 클라이언트 반환 (Round-Robin + RPM & 일일 제한 체크) - */ - public Client getAvailableClient() { - int attempts = 0; - int maxAttempts = clients.size() * 2; // 2번씩 시도 - - while (attempts < maxAttempts) { - int index = currentIndex.getAndUpdate(i -> (i + 1) % clients.size()); - String accountName = accountNames.get(index); - - // 1. 일일 제한 체크 (Redis) - String dailyKey = REDIS_KEY_DAILY + LocalDate.now() + ":" + accountName; - String dailyCallsStr = redisTemplate.opsForValue().get(dailyKey); - int dailyCalls = dailyCallsStr != null ? Integer.parseInt(dailyCallsStr) : 0; - - if (dailyCalls >= accountConfig.getMaxCallsPerDay()) { - log.debug("일일 호출 제한 도달: account={}, calls={}/{}", - accountName, dailyCalls, accountConfig.getMaxCallsPerDay()); - attempts++; - continue; - } - - // 2. Sliding Window Rate Limit 체크 - SlidingWindowRateLimiter rateLimiter = rateLimiters.get(accountName); - if (rateLimiter.tryAcquire()) { - log.info("✅ Gemini 클라이언트 획득: account={}", accountName); - return clients.get(index); - } - - log.info("⏱️ 분당 호출 제한 도달: account={}, RPM={}, 현재 윈도우 내 요청 수: {}", - accountName, - accountConfig.getRequestsPerMinute(), - rateLimiter.getCurrentCount()); - attempts++; - } - - // 모든 계정이 제한에 걸린 경우 - log.error("모든 Gemini 계정 호출 제한 도달"); - throw new CustomException(ErrorCode.GEMINI_RATE_LIMIT_EXCEEDED); - } - - /** - * 계정 이름으로 현재 사용 중인 계정명 반환 - */ - public String getCurrentAccountName() { - int index = (currentIndex.get() - 1 + clients.size()) % clients.size(); - return accountNames.get(index); - } - - /** - * 총 계정 수 반환 - */ - public int getAccountCount() { - return clients.size(); - } - - /** - * API 호출 성공 후 일일 카운트 증가 (Lua Script로 원자적 처리) - */ - public void incrementDailyCount(String accountName) { - String dailyKey = REDIS_KEY_DAILY + LocalDate.now() + ":" + accountName; - - // 자정까지 남은 시간 계산 - long secondsUntilMidnight = LocalTime.MAX.toSecondOfDay() - LocalTime.now().toSecondOfDay() + 1; - - // Lua 스크립트로 INCR + EXPIRE를 원자적으로 실행 - Long updatedDailyCount = redisTemplate.execute( - dailyCounterScript, - List.of(dailyKey), - String.valueOf(secondsUntilMidnight) - ); - - log.info("📊 Gemini API 호출 완료: account={}, 일일={}/{}", - accountName, updatedDailyCount, accountConfig.getMaxCallsPerDay()); - } - - /** - * 모든 계정의 현재 호출 횟수 조회 (모니터링용) - */ - public void logAllAccountUsage() { - LocalDate today = LocalDate.now(); - - for (String accountName : accountNames) { - String dailyKey = REDIS_KEY_DAILY + today + ":" + accountName; - String dailyCallsStr = redisTemplate.opsForValue().get(dailyKey); - int dailyCalls = dailyCallsStr != null ? Integer.parseInt(dailyCallsStr) : 0; - - log.info("Gemini 계정 사용량: account={}, 일일={}/{}", - accountName, - dailyCalls, accountConfig.getMaxCallsPerDay()); - } - } -} diff --git a/src/main/java/book/book/quiz/external/GeminiSdkClient.java b/src/main/java/book/book/quiz/external/GeminiSdkClient.java deleted file mode 100644 index 3c93bd79..00000000 --- a/src/main/java/book/book/quiz/external/GeminiSdkClient.java +++ /dev/null @@ -1,542 +0,0 @@ -package book.book.quiz.external; - -import book.book.book.entity.Chapter; -import book.book.common.CustomException; -import book.book.common.ErrorCode; -import book.book.quiz.dto.external.GeminiQuizResponse; -import book.book.quiz.dto.external.GeminiQuizResponse.ChoiceResponse; -import book.book.quiz.dto.external.GeminiQuizResponses; -import book.book.quiz.dto.external.GeminiQuizResponses.ChapterQuizResponse; -import book.book.quiz.dto.external.GeminiTokenUsage; -import book.book.quiz.exception.RateLimitException; -import book.book.quiz.service.GeminiTokenTracker; -import book.book.quiz.service.QuizAlertService; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.genai.Client; -import com.google.genai.types.Content; -import com.google.genai.types.GenerateContentConfig; -import com.google.genai.types.GenerateContentResponse; -import com.google.genai.types.Part; -import com.google.genai.types.Schema; -import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Slf4j -@Service -@RequiredArgsConstructor -public class GeminiSdkClient { - - private final ObjectMapper objectMapper; - private final QuizAlertService quizAlertService; - private final GeminiTokenTracker tokenTracker; - private final GeminiClientManager clientManager; - private final GeminiRetryHandler retryHandler; - private static final String MODEL = "gemini-2.5-flash"; - private static final float TEMPERATURE = 0.7f; - - private Schema createSingleQuizSchema() { - Schema choiceSchema = Schema.builder() - .type("object") - .properties(Map.of( - "choiceText", Schema.builder().type("string").build(), - "isCorrect", Schema.builder().type("boolean").build(), - "explanation", Schema.builder().type("string").build())) - .required(List.of("choiceText", "isCorrect", "explanation")) - .build(); - - return Schema.builder() - .type("object") - .properties(Map.of( - "question", Schema.builder().type("string").build(), - "choices", Schema.builder() - .type("array") - .items(choiceSchema) - .build())) - .required(List.of("question", "choices")) - .build(); - } - - private Schema createBatchQuizSchema() { - Schema choiceSchema = Schema.builder() - .type("object") - .properties(Map.of( - "choiceText", Schema.builder().type("string").build(), - "isCorrect", Schema.builder().type("boolean").build(), - "explanation", Schema.builder().type("string").build())) - .required(List.of("choiceText", "isCorrect", "explanation")) - .build(); - - Schema chapterQuizSchema = Schema.builder() - .type("object") - .properties(Map.of( - "chapterId", Schema.builder().type("integer").build(), // chapterId 추가 - "chapterTitle", Schema.builder().type("string").build(), - "question", Schema.builder().type("string").build(), - "choices", Schema.builder() - .type("array") - .items(choiceSchema) - .build())) - .required(List.of("chapterId", "chapterTitle", "question", "choices")) - .build(); - - return Schema.builder() - .type("object") - .properties(Map.of( - "quizzes", Schema.builder() - .type("array") - .items(chapterQuizSchema) - .build())) - .required(List.of("quizzes")) - .build(); - } - - private Content createSystemInstruction() { - String systemPrompt = """ - 당신은 독서 퀴즈 출제 전문가입니다. - - [핵심 목표] - 사용자가 해당 챕터를 정독하지 않았다면 틀릴 수밖에 없는 고난도 퀴즈를 생성하십시오. - - 퀴즈 생성 범위: - - 실제 책 내용이 있는 챕터에 대해서만 퀴즈를 생성합니다 - - 다음 챕터들은 퀴즈 생성에서 제외합니다: - * 서문, 머리말, 프롤로그 - * 작가의 말, 후기, 에필로그 - * 목차, 차례 - * 참고문헌, 색인 - * 기타 실제 본문 내용이 없는 섹션 - - - [핵심 지침] - 1. 정보 수집: 제공된 검색 도구를 활용하여 챕터별 내용뿐만 아니라, 해당 책에 대한 수준 높은 서평, 평론, 해석을 검색하십시오. 특히 문학 작품의 경우 단순 줄거리보다는 작가의 집필 의도나 사상을 분석한 글을 우선적으로 참고하십시오. - 2. 장르별 유연한 분석: - - 비문학(인문, 과학, 실용): 저자의 핵심 주장, 소개된 주요 개념 및 용어, 문제 해결 방법론, 통계적 근거. - - 문학(소설, 에세이): 표면적인 사건의 흐름보다는 작가가 은유적으로 표현하고자 한 사회적 메시지, 인간 본성에 대한 통찰, 작가의 철학. - 3. 단계별 사고: 문제를 출제하기 전, 검색된 분석 자료를 바탕으로 이 챕터에서 작가가 독자에게 전하고자 하는 궁극적인 메시지가 무엇인지 먼저 정의한 후 이를 문제화하십시오. - - [문제 구성 및 해설 작성 지침] - 1. 기본 구조: - - 모든 문제는 정확히 4개의 선택지로 구성된 객관식이어야 합니다. - - 정답은 4개의 선택지 중 오직 1개만 존재해야 하며, 정답의 위치는 무작위로 배정하십시오. - 2. 해설 작성 요건: - - 정답 해설: 작가의 의도나 책의 주제 의식과 연결하여 왜 이것이 정답인지 2문장에서 3문장으로 깊이 있게 설명하십시오. - - 오답 해설: 각 오답 선택지가 왜 틀렸는지, 어떤 부분에서 오류가 있는지 각각 1문장에서 2문장으로 명확히 설명하십시오. - - [퀴즈 품질 기준] - 1. 줄거리 질문 지양: - - 단순히 누가 무엇을 했는지, 사건이 어떤 순서로 일어났는지 묻는 1차원적인 줄거리 확인 문제는 철저히 배제하십시오. - - 독자가 책을 읽으며 느꼈을 의문이나, 작가가 던지는 화두를 퀴즈로 재구성하십시오. - 2. 매력적인 오답 구성: - - 정답과 비슷해 보이지만 작가의 핵심 사상과는 미묘하게 다른 견해나, 대중적인 통념이지만 이 책의 논지와는 반대되는 내용을 오답으로 배치하십시오. - - [치명적인 오답 설계 법칙] - 오답은 단순히 틀린 말이 아니라 그럴듯한 거짓말이어야 합니다. 다음 3가지 기법을 섞어서 오답을 만드십시오 - 1. 상식의 함정: - - 책의 내용과는 다르지만, 일반적인 사회 통념이나 도덕적으로 옳아 보이는 문장을 오답으로 제시하십시오. - - 예: 주인공은 갈등을 대화로 원만히 해결했다. (상식적으론 맞지만, 책에서는 파국으로 치달았다면 이것이 가장 매력적인 오답이 됨) - - 2. 키워드 뒤섞기: - - 해당 챕터에 실제로 등장하는 전문 용어나 인물의 이름을 사용하되, 그 인과관계나 주체를 반대로 서술하십시오. - - 읽지 않은 사람은 용어가 낯익어서 정답으로 착각하게 만드십시오. - - 3. 인과관계 비틀기: - - 결과는 맞지만 원인이 틀린 설명, 혹은 원인은 맞지만 결과가 다른 설명을 오답으로 제시하십시오. - - [정답 및 선택지 구성 지침] - 1. 길이 균형 유지: - - 정답이 오답보다 눈에 띄게 길어서는 안 됩니다. 모든 선택지의 길이를 비슷하게 맞추십시오. - 2. 랜덤 배치: - - 정답은 4개의 선택지 중 하나에 무작위로 배치하십시오. - 3. 명확한 정답: - - 챕터를 읽은 사람에게는 명확히 정답이 보이지만, 안 읽은 사람에게는 오답이 더 정답처럼 보여야 합니다. - - [응답 형식] - 1. 항상 정확한 JSON 형식으로만 응답 - 2. 스키마에 정의된 구조를 엄격히 준수 - """; - - return Content.fromParts(Part.fromText(systemPrompt)); - } - - /** - * 비동기 배치 퀴즈 생성 (429 에러 시 자동 재시도) - */ - // @Async - public CompletableFuture generateBatchQuizzesAsync(String bookTitle, String author, - List chapters) { - return retryHandler.executeWithRetryAsync( - () -> callGeminiApiBatchWithCircuitBreaker(bookTitle, author, chapters), - bookTitle, - 0); - } - - /** - * Circuit Breaker가 적용된 실제 Gemini API 호출 (비동기 배치) - * 429 에러는 RateLimitException으로 변환되어 Circuit Breaker에서 무시됨 - */ - @CircuitBreaker(name = "geminiApi") - private CompletableFuture callGeminiApiBatchWithCircuitBreaker( - String bookTitle, String author, List chapters) { - - // 사용 가능한 클라이언트 선택 (로드밸런싱) - Client client = clientManager.getAvailableClient(); - String accountName = clientManager.getCurrentAccountName(); - - String prompt = buildBatchPrompt(bookTitle, author, chapters); - - log.info("Gemini API 배치 요청: book={}, account={}, chapterCount={}, prompt length={}", - bookTitle, accountName, chapters.size(), prompt.length()); - log.debug("Gemini API 전체 프롬프트: book={}, prompt={}", bookTitle, prompt); - - GenerateContentConfig config = GenerateContentConfig.builder() - .systemInstruction(createSystemInstruction()) - .temperature(TEMPERATURE) - .responseMimeType("application/json") - .responseSchema(createBatchQuizSchema()) - .build(); - - // 비동기 API 호출 - return client.async.models.generateContent(MODEL, prompt, config) - .thenApply(response -> { - try { - // API 호출 성공 - 일일 카운트 증가 - clientManager.incrementDailyCount(accountName); - - // 토큰 사용량 기록 - recordTokenUsage(response); - - GeminiQuizResponses batchResponse = objectMapper.readValue( - response.text(), - GeminiQuizResponses.class); - - log.info("Gemini API 배치 응답 수신: book={}, 요청 챕터 수={}, 응답 퀴즈 수={}", - bookTitle, chapters.size(), - batchResponse.getQuizzes() != null - ? batchResponse.getQuizzes().size() - : 0); - - // 응답받은 챕터 제목들 로깅 - if (batchResponse.getQuizzes() != null) { - List receivedChapterTitles = batchResponse.getQuizzes() - .stream() - .map(ChapterQuizResponse::getChapterTitle) - .toList(); - log.info("Gemini 응답 챕터 목록: book={}, chapters={}", bookTitle, - receivedChapterTitles); - } - - log.debug("Gemini API 응답 상세: book={}, response={}", bookTitle, - response.text()); - - // 응답 유효성 검증 - if (!batchResponse.isValid()) { - log.warn("AI 배치 응답 유효성 검증 실패: book={}, chapters={}", bookTitle, - chapters.size()); - throw new CustomException(ErrorCode.INVALID_QUIZ_FORMAT); - } - - log.info("Gemini SDK 비동기 배치 퀴즈 생성 완료: book={}, account={}", bookTitle, - accountName); - return batchResponse; - - } catch (Exception e) { - log.error("Gemini SDK 응답 파싱 실패: book={}, error={}", bookTitle, - e.getMessage(), e); - throw new CustomException(ErrorCode.QUIZ_GENERATION_FAILED); - } - }) - .exceptionally(ex -> { - log.warn("Gemini SDK 배치 퀴즈 생성 실패 (account={}): book={}, error={}", - accountName, bookTitle, ex.getMessage()); - - // 429 에러는 RateLimitException으로 변환 (Circuit Breaker가 무시함) - if (retryHandler.isRateLimitError(ex)) { - throw new RateLimitException(ex); - } - - // 다른 에러는 그대로 전파 (Circuit Breaker가 추적) - throw new CompletionException(ex); - }); - } - - /** - * 동기 배치 퀴즈 생성 (429 에러 시 자동 재시도) - */ - public GeminiQuizResponses generateBatchQuizzes(String bookTitle, String author, - List chapters) { - return retryHandler.executeWithRetry( - () -> callGeminiApiBatchSyncWithCircuitBreaker(bookTitle, author, chapters), - bookTitle); - } - - /** - * Circuit Breaker가 적용된 실제 Gemini API 호출 (동기 배치) - * 429 에러는 RateLimitException으로 변환되어 Circuit Breaker에서 무시됨 - */ - @CircuitBreaker(name = "geminiApi") - private GeminiQuizResponses callGeminiApiBatchSyncWithCircuitBreaker( - String bookTitle, String author, List chapters) { - - // 사용 가능한 클라이언트 선택 (로드밸런싱) - Client client = clientManager.getAvailableClient(); - String accountName = clientManager.getCurrentAccountName(); - - try { - String prompt = buildBatchPrompt(bookTitle, author, chapters); - - log.info("Gemini API 동기 배치 요청: book={}, account={}, chapterCount={}", - bookTitle, accountName, chapters.size()); - log.debug("Gemini API 전체 프롬프트: book={}, prompt={}", bookTitle, prompt); - - GenerateContentConfig config = GenerateContentConfig.builder() - .systemInstruction(createSystemInstruction()) - .temperature(TEMPERATURE) - .responseMimeType("application/json") - .responseSchema(createBatchQuizSchema()) - .build(); - - GenerateContentResponse response = client.models.generateContent( - MODEL, - prompt, - config); - - // API 호출 성공 - 일일 카운트 증가 - clientManager.incrementDailyCount(accountName); - - // 토큰 사용량 기록 - recordTokenUsage(response); - - GeminiQuizResponses batchResponse = objectMapper.readValue(response.text(), - GeminiQuizResponses.class); - - log.info("Gemini API 동기 배치 응답 수신: book={}, 요청 챕터 수={}, 응답 퀴즈 수={}", - bookTitle, chapters.size(), - batchResponse.getQuizzes() != null ? batchResponse.getQuizzes().size() : 0); - - // 응답받은 챕터 제목들 로깅 - if (batchResponse.getQuizzes() != null) { - List receivedChapterTitles = batchResponse.getQuizzes().stream() - .map(ChapterQuizResponse::getChapterTitle) - .toList(); - log.info("Gemini 응답 챕터 목록: book={}, chapters={}", bookTitle, receivedChapterTitles); - } - - log.debug("Gemini API 응답 상세: book={}, response={}", bookTitle, response.text()); - - // 응답 유효성 검증 - if (!batchResponse.isValid()) { - log.warn("AI 배치 응답 유효성 검증 실패: book={}, chapters={}", bookTitle, chapters.size()); - throw new CustomException(ErrorCode.INVALID_QUIZ_FORMAT); - } - - log.info("Gemini SDK 배치 퀴즈 생성 완료: book={}, account={}", bookTitle, accountName); - return batchResponse; - - } catch (Exception e) { - log.warn("Gemini SDK 배치 퀴즈 생성 실패 (account={}): book={}, error={}", - accountName, bookTitle, e.getMessage()); - - // 429 에러는 RateLimitException으로 변환 (Circuit Breaker가 무시함) - if (retryHandler.isRateLimitError(e)) { - throw new RateLimitException(e); - } - - // 다른 에러는 그대로 전파 (Circuit Breaker가 추적) - throw new CustomException(ErrorCode.QUIZ_GENERATION_FAILED, e); - } - } - - /** - * 동기 단일 퀴즈 생성 (429 에러 시 자동 재시도) - */ - public GeminiQuizResponse generateQuiz(String bookTitle, String author, String chapterTitle) { - return retryHandler.executeWithRetry( - () -> callGeminiApiSingleWithCircuitBreaker(bookTitle, author, chapterTitle), - bookTitle); - } - - /** - * Circuit Breaker가 적용된 실제 Gemini API 호출 (단일 퀴즈) - * 429 에러는 RateLimitException으로 변환되어 Circuit Breaker에서 무시됨 - */ - @CircuitBreaker(name = "geminiApi") - private GeminiQuizResponse callGeminiApiSingleWithCircuitBreaker( - String bookTitle, String author, String chapterTitle) { - - // 사용 가능한 클라이언트 선택 - Client client = clientManager.getAvailableClient(); - String accountName = clientManager.getCurrentAccountName(); - - try { - String prompt = buildPrompt(bookTitle, author, chapterTitle); - - GenerateContentConfig config = GenerateContentConfig.builder() - .systemInstruction(createSystemInstruction()) - .temperature(TEMPERATURE) - .responseMimeType("application/json") - .responseSchema(createSingleQuizSchema()) - .build(); - - GenerateContentResponse response = client.models.generateContent( - MODEL, - prompt, - config); - - // API 호출 성공 - 일일 카운트 증가 - clientManager.incrementDailyCount(accountName); - - // 토큰 사용량 기록 - recordTokenUsage(response); - - GeminiQuizResponse quizResponse = objectMapper.readValue(response.text(), - GeminiQuizResponse.class); - - // 응답 유효성 검증 - if (!quizResponse.isValid()) { - log.warn("AI 응답 유효성 검증 실패: book={}, chapter={}", bookTitle, chapterTitle); - throw new CustomException(ErrorCode.INVALID_QUIZ_FORMAT); - } - - log.info("Gemini SDK 퀴즈 생성 완료: book={}, chapter={}, account={}", bookTitle, chapterTitle, - accountName); - return quizResponse; - - } catch (Exception e) { - log.warn("Gemini SDK 퀴즈 생성 실패 (account={}): book={}, chapter={}, error={}", - accountName, bookTitle, chapterTitle, e.getMessage()); - - // 429 에러는 RateLimitException으로 변환 (Circuit Breaker가 무시함) - if (retryHandler.isRateLimitError(e)) { - throw new RateLimitException(e); - } - - // 다른 에러는 그대로 전파 (Circuit Breaker가 추적) - throw new CustomException(ErrorCode.QUIZ_GENERATION_FAILED, e); - } - } - - private String buildPrompt(String bookTitle, String author, String chapterTitle) { - return String.format(""" - 다음 책의 특정 챕터에 대한 퀴즈를 생성해주세요. - - 책 정보: - - 제목: %s - - 저자: %s - - 챕터: %s - """, bookTitle, author, chapterTitle); - } - - private String buildBatchPrompt(String bookTitle, String author, List chapters) { - StringBuilder chapterList = new StringBuilder(); - for (Chapter chapter : chapters) { - chapterList.append(String.format("ID: %d, Title: %s\n", chapter.getId(), chapter.getTitle())); - } - - return String.format(""" - 다음 책의 챕터들에 대한 퀴즈를 생성해주세요. - - 책 정보: - - 제목: %s - - 저자: %s - - 챕터 목록: - %s - 각 챕터의 핵심 내용을 다루는 퀴즈를 생성해주세요. - 내용이 있는 챕터에 대해서만 퀴즈를 생성하면 됩니다. - 응답 시 반드시 제공된 'ID'를 'chapterId' 필드에 포함해야 합니다. - """, bookTitle, author, chapterList.toString()); - } - - private void recordTokenUsage(GenerateContentResponse response) { - try { - var usageMetadata = response.usageMetadata() - .orElseThrow(() -> new IllegalStateException("UsageMetadata가 응답에 포함되지 않았습니다")); - - int inputTokens = usageMetadata.promptTokenCount().orElse(0); - int outputTokens = usageMetadata.candidatesTokenCount().orElse(0); - - // 토큰 사용량 기록 - GeminiTokenUsage tokenUsage = GeminiTokenUsage.from( - inputTokens, outputTokens, MODEL, "quiz_generation"); - - tokenTracker.recordTokenUsage(tokenUsage); - - log.debug("토큰 사용량 기록 완료: input={}, output={}, total={}", - inputTokens, outputTokens, inputTokens + outputTokens); - - } catch (Exception e) { - log.error("토큰 사용량 기록 실패 (API 호출은 성공): {}", e.getMessage(), e); - } - } - - // Batch Fallback 메서드 - public GeminiQuizResponses generateBatchQuizzesFallback(String bookTitle, String author, - List chapters, - Throwable ex) { - log.error("Gemini SDK Batch Fallback 실행: book={}, author={}, chapters={}, error={}", - bookTitle, author, chapters.size(), ex.getMessage()); - - Exception e = (ex instanceof Exception) ? (Exception) ex : new Exception(ex); - // Circuit Breaker 상태에 따른 알림 - quizAlertService.handleException(0L, e); - - // 각 챕터별 기본 퀴즈 생성 - List fallbackQuizzes = chapters.stream() - .map(chapter -> createFallbackChapterQuiz(bookTitle, chapter.getTitle())) - .toList(); - - return GeminiQuizResponses.builder() - .quizzes(fallbackQuizzes) - .build(); - } - - private ChapterQuizResponse createFallbackChapterQuiz(String bookTitle, String chapterTitle) { - List fallbackChoices = List.of( - ChoiceResponse.builder() - .choiceText("독서를 통해 새로운 지식과 통찰을 얻기 위해서") - .isCorrect(true) - .explanation("독서는 우리에게 새로운 관점과 지식을 제공하여 개인적 성장을 도모합니다.") - .build(), - ChoiceResponse.builder() - .choiceText("시간을 때우기 위해서") - .isCorrect(false) - .explanation("단순히 시간을 때우는 것은 독서의 진정한 가치를 놓치는 것입니다.") - .build(), - ChoiceResponse.builder() - .choiceText("남들에게 보여주기 위해서") - .isCorrect(false) - .explanation("타인의 시선을 의식한 독서는 진정한 학습 효과를 얻기 어렵습니다.") - .build(), - ChoiceResponse.builder() - .choiceText("의무감 때문에") - .isCorrect(false) - .explanation("의무감만으로 하는 독서는 지속가능하지 않고 즐거움을 잃게 됩니다.") - .build()); - - return ChapterQuizResponse.builder() - .chapterTitle(chapterTitle) - .question(String.format("'%s'의 '%s'를 읽는 이유로 가장 적절한 것은?", bookTitle, chapterTitle)) - .choices(fallbackChoices) - .build(); - } - - // Fallback 메서드 - public GeminiQuizResponse generateQuizFallback(String bookTitle, String author, String chapterTitle, - Throwable ex) { - log.error("Gemini SDK Fallback 실행: book={}, author={}, chapter={}, error={}", - bookTitle, author, chapterTitle, ex.getMessage()); - - Exception e = (ex instanceof Exception) ? (Exception) ex : new Exception(ex); - // Circuit Breaker 상태에 따른 알림 - quizAlertService.handleException(0L, e); - - // 기본 퀴즈 반환 (공통 질문) - return GeminiQuizResponse.createFallbackQuiz(bookTitle, chapterTitle); - } -} diff --git a/src/main/java/book/book/quiz/external/SlidingWindowRateLimiter.java b/src/main/java/book/book/quiz/external/SlidingWindowRateLimiter.java deleted file mode 100644 index 9055399f..00000000 --- a/src/main/java/book/book/quiz/external/SlidingWindowRateLimiter.java +++ /dev/null @@ -1,88 +0,0 @@ -package book.book.quiz.external; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.script.RedisScript; - -import java.time.Duration; -import java.util.List; - -/** - * Sliding Window Rate Limiter (Redis Sorted Set 기반) - * - * 책임: - * - 정확한 Sliding Window 알고리즘으로 분당 요청 제한 - * - 어느 시점에서든 "최근 1분간 요청 수"를 정확히 계산 - * - Redis Lua Script를 통한 원자적 처리 - * - * 설계 의도: - * - Fixed Window의 경계 문제 해결 (예: 14:30:59 + 14:31:00 동시 burst 방지) - * - Gemini API의 실제 Rate Limit 정책과 정확히 일치 - * - Sorted Set으로 timestamp 기반 관리 (메모리 효율적) - * - * 동작 원리: - * 1. 현재 시간에서 1분 전까지의 요청만 카운트 - * 2. 오래된 요청은 자동 제거 (ZREMRANGEBYSCORE) - * 3. 제한 체크 후 현재 요청 추가 (ZADD) - * - * 예시: - * - 14:30:45 시점: 14:29:45 ~ 14:30:45 사이 요청만 카운트 - * - 14:31:00 시점: 14:30:00 ~ 14:31:00 사이 요청만 카운트 - */ -@Slf4j -@RequiredArgsConstructor -public class SlidingWindowRateLimiter { - - private final RedisTemplate redisTemplate; - private final RedisScript rateLimiterScript; - private final int maxRequests; - private final Duration windowDuration; - private final String accountName; - - private static final String KEY_PREFIX = "gemini:sliding-calls:"; - - /** - * Rate Limit 체크 및 토큰 획득 시도 (Sliding Window) - * - * @return true: 토큰 획득 성공, false: 제한 도달 - */ - public boolean tryAcquire() { - long currentTimeMillis = System.currentTimeMillis(); - String key = KEY_PREFIX + accountName; - - // Lua 스크립트로 Sliding Window 로직을 원자적으로 실행 - Long result = redisTemplate.execute( - rateLimiterScript, - List.of(key), - String.valueOf(maxRequests), - String.valueOf(currentTimeMillis), - String.valueOf(windowDuration.toMillis()) - ); - - if (result == null || result == 0) { - log.debug("Sliding Window 제한 도달: account={}, RPM={}", - accountName, maxRequests); - return false; - } - - log.debug("Sliding Window permit 획득: account={}, RPM={}", - accountName, maxRequests); - return true; - } - - /** - * 현재 윈도우 내 요청 수 조회 (모니터링용) - * - * @return 최근 1분간 요청 수 - */ - public long getCurrentCount() { - long currentTimeMillis = System.currentTimeMillis(); - long windowStart = currentTimeMillis - windowDuration.toMillis(); - String key = KEY_PREFIX + accountName; - - Long count = redisTemplate.opsForZSet().count(key, windowStart, currentTimeMillis); - - return count != null ? count : 0; - } -} diff --git a/src/main/java/book/book/quiz/external/gemini/GeminiApiService.java b/src/main/java/book/book/quiz/external/gemini/GeminiApiService.java new file mode 100644 index 00000000..94092998 --- /dev/null +++ b/src/main/java/book/book/quiz/external/gemini/GeminiApiService.java @@ -0,0 +1,54 @@ +package book.book.quiz.external.gemini; + +import book.book.common.CustomObjectMapper; +import book.book.quiz.dto.external.GeminiRequest; +import book.book.quiz.external.gemini.service.GeminiCircuitBreakerService; +import book.book.quiz.external.gemini.service.GeminiRetryHandler; +import com.google.genai.types.GenerateContentResponse; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * Gemini API 서비스 + * + * 해당 클래스로 geminiApi 호출을 하여야 함 + * 키 돌려쓰기 재시도 핸들러 및 서킷 브레이커 서비스 통합 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GeminiApiService { + + private final GeminiRetryHandler retryHandler; + private final GeminiCircuitBreakerService circuitBreakerService; + private final CustomObjectMapper customObjectMapper; + + /** + * 비동기 콘텐츠 생성 (429 에러 시 자동 재시도) + * PRM과 PRD를 피하기 위함 + * GeminiRequestFactory 에서 미리 만들어진 request 객체를 넘겨받아 처리 + */ + public CompletableFuture generateContentAsync(GeminiRequest request) { + return retryHandler.executeWithRetryAsync( + (context) -> circuitBreakerService.callGeminiApiAsyncWithCircuitBreaker(context, + request), + request.getContextForLogging(), + 0) + .thenApply(response -> customObjectMapper.readValue(response.text(), + request.getResponseType())); + } + + /** + * 동기 콘텐츠 생성 (429 에러 시 자동 재시도) + */ + public T generateContent(GeminiRequest request) { + GenerateContentResponse response = retryHandler.executeWithRetry( + (context) -> circuitBreakerService.callGeminiApiWithCircuitBreaker(context, request), + request.getContextForLogging()); + + return customObjectMapper.readValue(response.text(), request.getResponseType()); + } + +} diff --git a/src/main/java/book/book/quiz/external/gemini/apikey/GeminiClientContext.java b/src/main/java/book/book/quiz/external/gemini/apikey/GeminiClientContext.java new file mode 100644 index 00000000..cccd5db3 --- /dev/null +++ b/src/main/java/book/book/quiz/external/gemini/apikey/GeminiClientContext.java @@ -0,0 +1,6 @@ +package book.book.quiz.external.gemini.apikey; + +import com.google.genai.Client; + +public record GeminiClientContext(Client client, String accountName) { +} diff --git a/src/main/java/book/book/quiz/external/gemini/apikey/GeminiClientManager.java b/src/main/java/book/book/quiz/external/gemini/apikey/GeminiClientManager.java new file mode 100644 index 00000000..20e1ad2f --- /dev/null +++ b/src/main/java/book/book/quiz/external/gemini/apikey/GeminiClientManager.java @@ -0,0 +1,86 @@ +package book.book.quiz.external.gemini.apikey; + +import book.book.common.CustomException; +import book.book.common.ErrorCode; +import book.book.quiz.config.GeminiAccountConfig; +import book.book.quiz.config.GeminiAccountConfig.Account; +import book.book.quiz.external.gemini.service.GeminiRateLimiterService; +import com.google.genai.Client; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * Gemini 클라이언트 관리 및 로드밸런싱 + * - 여러 Gemini 계정을 초기화하고 관리 + * - Round-Robin 방식으로 로드밸런싱 + * - RateLimiterService를 통한 제한 체크 + */ +@Slf4j +@Component +public class GeminiClientManager { + + private final GeminiRateLimiterService rateLimiterService; + private final List clients; + private final List accountNames; + private final AtomicInteger currentIndex = new AtomicInteger(0); + + public GeminiClientManager(GeminiAccountConfig geminiAccountConfig, GeminiRateLimiterService rateLimiterService) { + this.rateLimiterService = rateLimiterService; + + List clientList = new ArrayList<>(); + List nameList = new ArrayList<>(); + + for (Account account : geminiAccountConfig.getAccounts()) { + try { + Client client = Client.builder() + .apiKey(account.getApiKey()) + .build(); + + clientList.add(client); + nameList.add(account.getName()); + log.info("Gemini Client 등록 성공: {}", account.getName()); + } catch (Exception e) { + log.error("Gemini Client 초기화 실패: {}", account.getName(), e); + } + } + + this.clients = Collections.unmodifiableList(clientList); + this.accountNames = Collections.unmodifiableList(nameList); + } + + /** + * 사용 가능한 Gemini 클라이언트 반환 (Round-Robin + RPM & 일일 제한 체크) + */ + public GeminiClientContext getAvailableClient() { + int count = clients.size(); + int attempts = 0; + int maxAttempts = count * 2; // 2번씩 시도 + + while (attempts < maxAttempts) { + int index = currentIndex.getAndUpdate(i -> (i + 1) % count); + String accountName = accountNames.get(index); + + // 1. 통합 Rate Limit 체크 (Daily + RPM Atomic Check) + if (rateLimiterService.acquirePermit(accountName)) { + return new GeminiClientContext(clients.get(index), accountName); + } + + attempts++; + } + + // 모든 계정이 제한에 걸린 경우 + log.error("모든 Gemini 계정 호출 제한 도달"); + throw new CustomException(ErrorCode.GEMINI_RATE_LIMIT_EXCEEDED); + } + + /** + * 총 계정 수 반환 + */ + public int getAccountCount() { + return clients.size(); + } +} diff --git a/src/main/java/book/book/quiz/external/gemini/prompt/GeminiQuizPromptProvider.java b/src/main/java/book/book/quiz/external/gemini/prompt/GeminiQuizPromptProvider.java new file mode 100644 index 00000000..87cf1eb5 --- /dev/null +++ b/src/main/java/book/book/quiz/external/gemini/prompt/GeminiQuizPromptProvider.java @@ -0,0 +1,169 @@ +package book.book.quiz.external.gemini.prompt; + +import book.book.book.entity.Chapter; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import com.google.genai.types.Schema; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Component; + +@Component +public class GeminiQuizPromptProvider { + + public Schema createSingleQuizResponseSchema() { + Schema choiceSchema = Schema.builder() + .type("object") + .properties(Map.of( + "choiceText", Schema.builder().type("string").build(), + "isCorrect", Schema.builder().type("boolean").build(), + "explanation", Schema.builder().type("string").build())) + .required(List.of("choiceText", "isCorrect", "explanation")) + .build(); + + return Schema.builder() + .type("object") + .properties(Map.of( + "question", Schema.builder().type("string").build(), + "choices", Schema.builder() + .type("array") + .items(choiceSchema) + .build())) + .required(List.of("question", "choices")) + .build(); + } + + public Schema createBatchQuizResponseSchema() { + Schema choiceSchema = Schema.builder() + .type("object") + .properties(Map.of( + "choiceText", Schema.builder().type("string").build(), + "isCorrect", Schema.builder().type("boolean").build(), + "explanation", Schema.builder().type("string").build())) + .required(List.of("choiceText", "isCorrect", "explanation")) + .build(); + + Schema chapterQuizSchema = Schema.builder() + .type("object") + .properties(Map.of( + "chapterId", Schema.builder().type("integer").build(), + "chapterTitle", Schema.builder().type("string").build(), + "question", Schema.builder().type("string").build(), + "choices", Schema.builder() + .type("array") + .items(choiceSchema) + .build())) + .required(List.of("chapterId", "chapterTitle", "question", "choices")) + .build(); + + return Schema.builder() + .type("object") + .properties(Map.of( + "quizzes", Schema.builder() + .type("array") + .items(chapterQuizSchema) + .build())) + .required(List.of("quizzes")) + .build(); + } + + public Content createSystemInstruction() { + String systemPrompt = """ + 당신은 독서 퀴즈 출제 전문가입니다. + + [핵심 목표] + 사용자가 해당 챕터를 정독하지 않았다면 틀릴 수밖에 없는 고난도 퀴즈를 생성하십시오. + + 퀴즈 생성 범위: + - 실제 책 내용이 있는 챕터에 대해서만 퀴즈를 생성합니다 + - 다음 챕터들은 퀴즈 생성에서 제외합니다: + * 서문, 머리말, 프롤로그 + * 작가의 말, 후기, 에필로그 + * 목차, 차례 + * 참고문헌, 색인 + * 기타 실제 본문 내용이 없는 섹션 + + + [핵심 지침] + 1. 정보 수집: 제공된 검색 도구를 활용하여 챕터별 내용뿐만 아니라, 해당 책에 대한 수준 높은 서평, 평론, 해석을 검색하십시오. 특히 문학 작품의 경우 단순 줄거리보다는 작가의 집필 의도나 사상을 분석한 글을 우선적으로 참고하십시오. + 2. 장르별 유연한 분석: + - 비문학(인문, 과학, 실용): 저자의 핵심 주장, 소개된 주요 개념 및 용어, 문제 해결 방법론, 통계적 근거. + - 문학(소설, 에세이): 표면적인 사건의 흐름보다는 작가가 은유적으로 표현하고자 한 사회적 메시지, 인간 본성에 대한 통찰, 작가의 철학. + 3. 단계별 사고: 문제를 출제하기 전, 검색된 분석 자료를 바탕으로 이 챕터에서 작가가 독자에게 전하고자 하는 궁극적인 메시지가 무엇인지 먼저 정의한 후 이를 문제화하십시오. + + [문제 구성 및 해설 작성 지침] + 1. 기본 구조: + - 모든 문제는 정확히 4개의 선택지로 구성된 객관식이어야 합니다. + - 정답은 4개의 선택지 중 오직 1개만 존재해야 하며, 정답의 위치는 무작위로 배정하십시오. + 2. 해설 작성 요건: + - 정답 해설: 작가의 의도나 책의 주제 의식과 연결하여 왜 이것이 정답인지 2문장에서 3문장으로 깊이 있게 설명하십시오. + - 오답 해설: 각 오답 선택지가 왜 틀렸는지, 어떤 부분에서 오류가 있는지 각각 1문장에서 2문장으로 명확히 설명하십시오. + + [퀴즈 품질 기준] + 1. 줄거리 질문 지양: + - 단순히 누가 무엇을 했는지, 사건이 어떤 순서로 일어났는지 묻는 1차원적인 줄거리 확인 문제는 철저히 배제하십시오. + - 독자가 책을 읽으며 느꼈을 의문이나, 작가가 던지는 화두를 퀴즈로 재구성하십시오. + 2. 매력적인 오답 구성: + - 정답과 비슷해 보이지만 작가의 핵심 사상과는 미묘하게 다른 견해나, 대중적인 통념이지만 이 책의 논지와는 반대되는 내용을 오답으로 배치하십시오. + + [치명적인 오답 설계 법칙] + 오답은 단순히 틀린 말이 아니라 그럴듯한 거짓말이어야 합니다. 다음 3가지 기법을 섞어서 오답을 만드십시오 + 1. 상식의 함정: + - 책의 내용과는 다르지만, 일반적인 사회 통념이나 도덕적으로 옳아 보이는 문장을 오답으로 제시하십시오. + - 예: 주인공은 갈등을 대화로 원만히 해결했다. (상식적으론 맞지만, 책에서는 파국으로 치달았다면 이것이 가장 매력적인 오답이 됨) + + 2. 키워드 뒤섞기: + - 해당 챕터에 실제로 등장하는 전문 용어나 인물의 이름을 사용하되, 그 인과관계나 주체를 반대로 서술하십시오. + - 읽지 않은 사람은 용어가 낯익어서 정답으로 착각하게 만드십시오. + + 3. 인과관계 비틀기: + - 결과는 맞지만 원인이 틀린 설명, 혹은 원인은 맞지만 결과가 다른 설명을 오답으로 제시하십시오. + + [정답 및 선택지 구성 지침] + 1. 길이 균형 유지: + - 정답이 오답보다 눈에 띄게 길어서는 안 됩니다. 모든 선택지의 길이를 비슷하게 맞추십시오. + 2. 랜덤 배치: + - 정답은 4개의 선택지 중 하나에 무작위로 배치하십시오. + 3. 명확한 정답: + - 챕터를 읽은 사람에게는 명확히 정답이 보이지만, 안 읽은 사람에게는 오답이 더 정답처럼 보여야 합니다. + + [응답 형식] + 1. 항상 정확한 JSON 형식으로만 응답 + 2. 스키마에 정의된 구조를 엄격히 준수 + """; + + return Content.fromParts(Part.fromText(systemPrompt)); + } + + public String buildSingleQuizPrompt(String bookTitle, String author, String chapterTitle) { + return String.format(""" + 다음 책의 특정 챕터에 대한 퀴즈를 생성해주세요. + + 책 정보: + - 제목: %s + - 저자: %s + - 챕터: %s + """, bookTitle, author, chapterTitle); + } + + public String buildBatchQuizPrompt(String bookTitle, String author, List chapters) { + StringBuilder chapterList = new StringBuilder(); + for (Chapter chapter : chapters) { + chapterList.append(String.format("ID: %d, Title: %s\n", chapter.getId(), chapter.getTitle())); + } + + return String.format(""" + 다음 책의 챕터들에 대한 퀴즈를 생성해주세요. + + 책 정보: + - 제목: %s + - 저자: %s + + 챕터 목록: + %s + 각 챕터의 핵심 내용을 다루는 퀴즈를 생성해주세요. + 내용이 있는 챕터에 대해서만 퀴즈를 생성하면 됩니다. + 응답 시 반드시 제공된 'ID'를 'chapterId' 필드에 포함해야 합니다. + """, bookTitle, author, chapterList.toString()); + } +} diff --git a/src/main/java/book/book/quiz/external/gemini/prompt/GeminiRequestFactory.java b/src/main/java/book/book/quiz/external/gemini/prompt/GeminiRequestFactory.java new file mode 100644 index 00000000..6ca768ad --- /dev/null +++ b/src/main/java/book/book/quiz/external/gemini/prompt/GeminiRequestFactory.java @@ -0,0 +1,33 @@ +package book.book.quiz.external.gemini.prompt; + +import book.book.book.entity.Book; +import book.book.book.entity.Chapter; +import book.book.quiz.dto.external.GeminiQuizResponses; +import book.book.quiz.dto.external.GeminiRequest; +import com.google.genai.types.Content; +import com.google.genai.types.Schema; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class GeminiRequestFactory { + + private final GeminiQuizPromptProvider quizPromptProvider; + + public GeminiRequest createBatchQuizRequest(Book book, List chapters) { + String prompt = quizPromptProvider.buildBatchQuizPrompt(book.getTitle(), book.getAuthor(), chapters); + Content systemInstruction = quizPromptProvider.createSystemInstruction(); + Schema schema = quizPromptProvider.createBatchQuizResponseSchema(); + + return GeminiRequest.builder() + .prompt(prompt) + .systemInstruction(systemInstruction) + .schema(schema) + .temperature(0.7f) + .responseType(GeminiQuizResponses.class) + .contextForLogging("[퀴즈생성]" + book.getTitle()) + .build(); + } +} diff --git a/src/main/java/book/book/quiz/external/gemini/service/GeminiCircuitBreakerService.java b/src/main/java/book/book/quiz/external/gemini/service/GeminiCircuitBreakerService.java new file mode 100644 index 00000000..2d1615a4 --- /dev/null +++ b/src/main/java/book/book/quiz/external/gemini/service/GeminiCircuitBreakerService.java @@ -0,0 +1,61 @@ +package book.book.quiz.external.gemini.service; + +import book.book.common.CustomException; +import book.book.common.ErrorCode; +import book.book.quiz.dto.external.GeminiRequest; +import book.book.quiz.exception.RateLimitException; +import book.book.quiz.external.gemini.apikey.GeminiClientContext; +import com.google.genai.types.GenerateContentResponse; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GeminiCircuitBreakerService { + + private final GeminiSdkClient geminiSdkClient; + private final GeminiRetryHandler retryHandler; + + /** + * Circuit Breaker가 적용된 실제 Gemini API 호출 (비동기) + */ + @CircuitBreaker(name = "geminiApi") + public CompletableFuture callGeminiApiAsyncWithCircuitBreaker( + GeminiClientContext context, GeminiRequest request) { + + // 비동기 API 호출 (GeminiApiClient 사용) + return geminiSdkClient.generateContentAsync(context, request) + .exceptionally(ex -> { + // 429 에러는 RateLimitException으로 변환 (Circuit Breaker가 무시함) + if (retryHandler.isRateLimitError(ex)) { + throw new RateLimitException(ex); + } + // 다른 에러는 그대로 전파 (Circuit Breaker가 추적) + throw new CompletionException(ex); + }); + } + + /** + * Circuit Breaker가 적용된 실제 Gemini API 호출 (동기) + */ + @CircuitBreaker(name = "geminiApi") + public GenerateContentResponse callGeminiApiWithCircuitBreaker(GeminiClientContext context, + GeminiRequest request) { + + try { + return geminiSdkClient.generateContent(context, request); + } catch (Exception e) { + // 429 에러는 RateLimitException으로 변환 (Circuit Breaker가 무시함) + if (retryHandler.isRateLimitError(e)) { + throw new RateLimitException(e); + } + // 다른 에러는 그대로 전파 (Circuit Breaker가 추적) + throw new CustomException(ErrorCode.QUIZ_GENERATION_FAILED, e); + } + } +} diff --git a/src/main/java/book/book/quiz/external/gemini/service/GeminiRateLimiterService.java b/src/main/java/book/book/quiz/external/gemini/service/GeminiRateLimiterService.java new file mode 100644 index 00000000..7cef639b --- /dev/null +++ b/src/main/java/book/book/quiz/external/gemini/service/GeminiRateLimiterService.java @@ -0,0 +1,92 @@ +package book.book.quiz.external.gemini.service; + +import book.book.quiz.config.GeminiAccountConfig; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.stereotype.Service; + +/** + * Sliding Window 알고리즘을 사용하여 분당 호출 제한 구현 + * + * Redis와 일일 호출 제한 및 분당 호출 제한 관리가 강하게 관리되어있어 repository가 아닌 서비스로 구현 + * 정책이 바뀌거나 레디스가 아닌 다른 구현체를 사용하더라도 GeminiRateLimiterService 전체가 달라지기 때문에, + * repository로 분리하지 않음 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GeminiRateLimiterService { + + private final GeminiAccountConfig accountConfig; + private final RedisTemplate redisTemplate; + + @Qualifier("acquirePermitScript") + private final RedisScript acquirePermitScript; + + private static final String REDIS_KEY_DAILY = "gemini:daily-calls:"; + private static final String REDIS_KEY_SLIDING = "gemini:sliding-calls:"; + private static final long WINDOW_DURATION_MS = 60000; // 1분 + + /** + * Gemini API 호출 권한 획득 (Atomic Daily + RPM Check) + * - Lua Script를 사용하여 일일 제한과 분당 제한을 원자적으로 검사하고 증가시킴 + */ + public boolean acquirePermit(String accountName) { + String dailyKey = REDIS_KEY_DAILY + LocalDate.now() + ":" + accountName; + String windowKey = REDIS_KEY_SLIDING + accountName; + long currentTimeMillis = System.currentTimeMillis(); + + // 자정까지 남은 시간 계산 (Daily Key TTL) + long secondsUntilMidnight = LocalTime.MAX.toSecondOfDay() - LocalTime.now().toSecondOfDay() + 1; + + Long result = redisTemplate.execute( + acquirePermitScript, + List.of(dailyKey, windowKey), + accountConfig.getMaxCallsPerDay(), + accountConfig.getRequestsPerMinute(), + currentTimeMillis, + WINDOW_DURATION_MS, + secondsUntilMidnight); + + if (result != null && result == 1) { + log.debug("✅ Gemini API Permit 획득: account={}", accountName); + return true; + } else { + log.warn("⛔ Gemini API Permit 획득 실패 (Limit Exceeded): account={}", accountName); + return false; + } + } + + /** + * 일일 호출 횟수 롤백 (보상 트랜잭션) + * - API 호출 실패 시 카운트 감소 + * - Sliding Window는 롤백하지 않음 (복잡도 대비 효용 낮음) + */ + public void rollbackDailyCall(String accountName) { + String dailyKey = REDIS_KEY_DAILY + LocalDate.now() + ":" + accountName; + redisTemplate.opsForValue().decrement(dailyKey); + log.debug("일일 호출 롤백: account={}", accountName); + } + + public long getCurrentCount(String accountName) { + long currentTimeMillis = System.currentTimeMillis(); + long windowStart = currentTimeMillis - WINDOW_DURATION_MS; + String key = REDIS_KEY_SLIDING + accountName; + + Long count = redisTemplate.opsForZSet().count(key, windowStart, currentTimeMillis); + + return count != null ? count : 0; + } + + public int getDailyUsage(String accountName) { + String dailyKey = REDIS_KEY_DAILY + LocalDate.now() + ":" + accountName; + Object dailyCallsObj = redisTemplate.opsForValue().get(dailyKey); + return dailyCallsObj != null ? Integer.parseInt(String.valueOf(dailyCallsObj)) : 0; + } +} diff --git a/src/main/java/book/book/quiz/external/GeminiRetryHandler.java b/src/main/java/book/book/quiz/external/gemini/service/GeminiRetryHandler.java similarity index 65% rename from src/main/java/book/book/quiz/external/GeminiRetryHandler.java rename to src/main/java/book/book/quiz/external/gemini/service/GeminiRetryHandler.java index f38f40ed..01b963e7 100644 --- a/src/main/java/book/book/quiz/external/GeminiRetryHandler.java +++ b/src/main/java/book/book/quiz/external/gemini/service/GeminiRetryHandler.java @@ -1,12 +1,15 @@ -package book.book.quiz.external; +package book.book.quiz.external.gemini.service; import book.book.common.CustomException; import book.book.common.ErrorCode; import book.book.quiz.exception.RateLimitException; +import book.book.quiz.external.gemini.apikey.GeminiClientContext; +import book.book.quiz.external.gemini.apikey.GeminiClientManager; import book.book.quiz.service.QuizAlertService; import com.google.genai.errors.ApiException; import java.util.concurrent.CompletableFuture; -import java.util.function.Supplier; + +import java.util.function.Function; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -21,9 +24,9 @@ * 4. 동기/비동기 모두 지원 * * 설계 의도: - * - SDK 직접 의존성 없음 (Supplier로 추상화) - * - 완전히 단위 테스트 가능 - * - GeminiSdkClient와 책임 분리 + * GeminiClientManager가 가능한 키를 한 번에 반환하도록 할 수 있지만, GeminiClientManager는 레디스를 사용하기 때문에 + * 레디스 장애 시 키 관리가 어려워질 수 있습니다. + * 따라서 재시도 로직을 이 핸들러에 두어, 각 호출 시점에 가능한 키를 선택하도록 하였습니다. */ @Slf4j @Component @@ -33,113 +36,113 @@ public class GeminiRetryHandler { private final GeminiClientManager clientManager; private final QuizAlertService quizAlertService; + /** * 동기 API 호출 with 자동 재시도 * - * @param apiCall 실제 API 호출 로직 (Circuit Breaker가 적용된 메서드) - * @param bookTitle 책 제목 (로깅용) + * @param apiCall 실제 API 호출 로직 (Context를 받아 실행) + * @param contextString 호출 컨텍스트 (로깅용, 예: 책 제목) * * @return API 호출 결과 * @throws CustomException 모든 계정 실패 또는 일반 에러 발생 시 */ public T executeWithRetry( - Supplier apiCall, - String bookTitle) { + Function apiCall, + String contextString) { int maxAttempts = clientManager.getAccountCount(); for (int attempt = 0; attempt < maxAttempts; attempt++) { + GeminiClientContext clientContext = clientManager.getAvailableClient(); + String accountName = clientContext.accountName(); + try { - log.info("Gemini API 호출 시도 #{}: book={}", attempt + 1, bookTitle); + log.info("Gemini API 호출 시도 #{}: context={}, account={}", attempt + 1, contextString, accountName); // Circuit Breaker가 적용된 실제 API 호출 - T result = apiCall.get(); + T result = apiCall.apply(clientContext); - log.info("Gemini API 호출 성공: book={}, attempt={}", bookTitle, attempt + 1); + log.info("Gemini API 호출 성공: context={}, attempt={}, account={}", contextString, attempt + 1, + accountName); return result; } catch (RateLimitException e) { - // 429 에러 - 다음 계정으로 재시도 - log.info("⚠️ Rate Limit 에러 감지 - 다른 계정으로 재시도 중: book={}, attempt={} -> {}", - bookTitle, attempt + 1, attempt + 2); - // 마지막 시도였으면 최종 실패 if (attempt == maxAttempts - 1) { - log.error("모든 계정에서 Rate Limit 에러 발생: book={}, attempts={}", bookTitle, maxAttempts); - String accountName = clientManager.getCurrentAccountName(); + log.error("모든 계정에서 Rate Limit 에러 발생: context={}, attempts={}", contextString, maxAttempts); quizAlertService.notifyAiApiFailure(0L, e, accountName); throw new CustomException(ErrorCode.QUIZ_GENERATION_FAILED); } - - // 다음 계정으로 재시도 - continue; - + // 429 에러 - 다음 계정으로 재시도 + log.info("⚠️ Rate Limit 에러 감지 - 다른 계정으로 재시도 중: context={}, attempt={} -> {}", + contextString, attempt + 1, attempt + 2); } catch (Exception e) { // 일반 에러 - 재시도하지 않고 최종 실패 - log.error("Gemini API 호출 최종 실패: book={}, attempt={}, error={}", - bookTitle, attempt + 1, e.getMessage(), e); + log.error("Gemini API 호출 최종 실패: context={}, attempt={}, error={}", + contextString, attempt + 1, e.getMessage(), e); - String accountName = clientManager.getCurrentAccountName(); quizAlertService.notifyAiApiFailure(0L, e, accountName); throw new CustomException(ErrorCode.QUIZ_GENERATION_FAILED, e); } } // 모든 재시도 실패 - log.error("모든 계정에서 API 호출 실패: book={}, attempts={}", bookTitle, maxAttempts); + log.error("모든 계정에서 API 호출 실패: context={}, attempts={}", contextString, maxAttempts); throw new CustomException(ErrorCode.QUIZ_GENERATION_FAILED); } /** * 비동기 API 호출 with 자동 재시도 (논블로킹 재귀) * - * @param apiCall 실제 비동기 API 호출 로직 (Circuit Breaker가 적용된 메서드) - * @param bookTitle 책 제목 (로깅용) - * @param attempt 현재 시도 횟수 (재귀 호출용) + * @param apiCall 실제 비동기 API 호출 로직 (Context를 받아 실행) + * @param contextString 호출 컨텍스트 (로깅용, 예: 책 제목) + * @param attempt 현재 시도 횟수 (재귀 호출용) * * @return API 호출 결과의 CompletableFuture */ public CompletableFuture executeWithRetryAsync( - Supplier> apiCall, - String bookTitle, + Function> apiCall, + String contextString, int attempt) { int maxAttempts = clientManager.getAccountCount(); // 모든 계정 시도 완료 시 실패 if (attempt >= maxAttempts) { - log.error("모든 계정에서 비동기 API 호출 실패: book={}, attempts={}", bookTitle, attempt); + log.error("모든 계정에서 비동기 API 호출 실패: context={}, attempts={}", contextString, attempt); return CompletableFuture.failedFuture( new CustomException(ErrorCode.QUIZ_GENERATION_FAILED)); } - log.info("Gemini API 비동기 호출 시도 #{}: book={}", attempt + 1, bookTitle); + GeminiClientContext clientContext = clientManager.getAvailableClient(); + String accountName = clientContext.accountName(); + + log.info("Gemini API 비동기 호출 시도 #{}: context={}, account={}", attempt + 1, contextString, accountName); // Circuit Breaker가 적용된 비동기 API 호출 - return apiCall.get() + return apiCall.apply(clientContext) .handle((result, ex) -> { // 성공한 경우 if (ex == null) { - log.info("Gemini API 비동기 호출 성공: book={}, attempt={}", - bookTitle, attempt + 1); + log.info("Gemini API 비동기 호출 성공: context={}, attempt={}, account={}", + contextString, attempt + 1, accountName); return CompletableFuture.completedFuture(result); } // 429 Rate Limit 에러인 경우 다음 계정으로 재시도 (논블로킹 재귀) if (ex instanceof RateLimitException || isRateLimitError(ex)) { - log.info("⚠️ Rate Limit 에러 감지 - 다른 계정으로 비동기 재시도 중: book={}, attempt={} -> {}", - bookTitle, attempt + 1, attempt + 2); + log.info("⚠️ Rate Limit 에러 감지 - 다른 계정으로 비동기 재시도 중: context={}, attempt={} -> {}", + contextString, attempt + 1, attempt + 2); // 논블로킹 재귀 호출 - return executeWithRetryAsync(apiCall, bookTitle, attempt + 1); + return executeWithRetryAsync(apiCall, contextString, attempt + 1); } // 다른 에러는 최종 실패 (Circuit Breaker가 처리한 에러) - log.error("Gemini API 비동기 호출 최종 실패: book={}, attempt={}, error={}", - bookTitle, attempt + 1, ex.getMessage()); + log.error("Gemini API 비동기 호출 최종 실패: context={}, attempt={}, error={}", + contextString, attempt + 1, ex.getMessage()); Exception exception = (ex instanceof Exception) ? (Exception) ex : new Exception(ex); - String accountName = clientManager.getCurrentAccountName(); quizAlertService.notifyAiApiFailure(0L, exception, accountName); return CompletableFuture.failedFuture( diff --git a/src/main/java/book/book/quiz/external/gemini/service/GeminiSdkClient.java b/src/main/java/book/book/quiz/external/gemini/service/GeminiSdkClient.java new file mode 100644 index 00000000..7146340e --- /dev/null +++ b/src/main/java/book/book/quiz/external/gemini/service/GeminiSdkClient.java @@ -0,0 +1,119 @@ +package book.book.quiz.external.gemini.service; + +import book.book.quiz.dto.external.GeminiRequest; +import book.book.quiz.external.gemini.apikey.GeminiClientContext; +import book.book.quiz.service.GeminiTokenTracker; +import com.google.genai.Client; +import com.google.genai.types.GenerateContentConfig; +import com.google.genai.types.GenerateContentResponse; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GeminiSdkClient { + + private final GeminiRateLimiterService rateLimiterService; + private final GeminiTokenTracker tokenTracker; + private static final String MODEL = "gemini-2.5-flash"; + + /** + * Synchronous API Call + */ + public GenerateContentResponse generateContent(GeminiClientContext context, GeminiRequest request) { + Client client = context.client(); + String accountName = context.accountName(); + + try { + log.info("Gemini API 요청 (동기): context={}, account={}", request.getContextForLogging(), + accountName); + + GenerateContentConfig config = GenerateContentConfig.builder() + .systemInstruction(request.getSystemInstruction()) + .temperature(request.getTemperature()) + .responseMimeType("application/json") + .responseSchema(request.getSchema()) + .build(); + + GenerateContentResponse response = generateContentInternal(client, request.getPrompt(), config); + + recordUsage(context, response); + + log.info("Gemini API 응답 (동기): context={}, account={}", request.getContextForLogging(), + accountName); + return response; + + } catch (Exception e) { + log.error("Gemini API 에러 (동기): context={}, account={}, error={}", + request.getContextForLogging(), + accountName, + e.getMessage()); + rateLimiterService.rollbackDailyCall(accountName); + throw e; + } + } + + protected GenerateContentResponse generateContentInternal(Client client, String prompt, + GenerateContentConfig config) { + return client.models.generateContent(MODEL, prompt, config); + } + + /** + * Asynchronous API Call + */ + public CompletableFuture generateContentAsync(GeminiClientContext context, + GeminiRequest request) { + Client client = context.client(); + String accountName = context.accountName(); + + log.info("Gemini API 요청 (비동기): context={}, account={}", request.getContextForLogging(), accountName); + + GenerateContentConfig config = GenerateContentConfig.builder() + .systemInstruction(request.getSystemInstruction()) + .temperature(request.getTemperature()) + .responseMimeType("application/json") + .responseSchema(request.getSchema()) + .build(); + + return generateContentAsyncInternal(client, request.getPrompt(), config) + .thenApply(response -> { + recordUsage(context, response); + + log.info("Gemini API 응답 (비동기): context={}, account={}", + request.getContextForLogging(), + accountName); + return response; + }) + .exceptionally(ex -> { + log.error("Gemini API 에러 (비동기): context={}, account={}, error={}", + request.getContextForLogging(), + accountName, + ex.getMessage()); + + // API 호출 실패 시 롤백 (환불) + rateLimiterService.rollbackDailyCall(accountName); + + if (ex instanceof RuntimeException) { + throw (RuntimeException) ex; + } + throw new RuntimeException(ex); + }); + } + + protected CompletableFuture generateContentAsyncInternal(Client client, String prompt, + GenerateContentConfig config) { + return client.async.models.generateContent(MODEL, prompt, config); + } + + private void recordUsage(GeminiClientContext context, GenerateContentResponse response) { + try { + // incrementDailyCount는 acquirePermit 단계에서 이미 수행되었으므로 제거 + tokenTracker.recordTokenUsage(context.accountName(), response); + } catch (Exception e) { + log.error("Gemini 사용량 기록 실패: account={}, error={}", context.accountName(), e.getMessage(), e); + } + } +} diff --git a/src/main/java/book/book/quiz/scheduler/GeminiMonitoringScheduler.java b/src/main/java/book/book/quiz/scheduler/GeminiMonitoringScheduler.java new file mode 100644 index 00000000..c9362088 --- /dev/null +++ b/src/main/java/book/book/quiz/scheduler/GeminiMonitoringScheduler.java @@ -0,0 +1,61 @@ +package book.book.quiz.scheduler; + +import book.book.quiz.config.GeminiAccountConfig; +import book.book.quiz.config.GeminiAccountConfig.Account; +import book.book.quiz.external.gemini.service.GeminiRateLimiterService; +import book.book.quiz.service.GeminiTokenTracker; +import book.book.quiz.service.QuizAlertService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GeminiMonitoringScheduler { + + private final GeminiRateLimiterService rateLimiterService; + private final GeminiAccountConfig accountConfig; + private final QuizAlertService quizAlertService; + private final GeminiTokenTracker tokenTracker; + + /** + * 1시간마다 모든 계정의 사용량 디스코드 알림 전송 + */ + @Scheduled(cron = "0 0 * * * *") + public void reportAllAccountUsage() { + StringBuilder report = new StringBuilder(); + + for (Account account : accountConfig.getAccounts()) { + int currentUsage = rateLimiterService.getDailyUsage(account.getName()); + int limit = accountConfig.getMaxCallsPerDay(); + double usagePercent = ((double) currentUsage / limit) * 100; + + report.append(String.format("- **%s**: %,d/%,d (%.1f%%)\n", + account.getName(), currentUsage, limit, usagePercent)); + } + + quizAlertService.sendAccountUsageReport(report.toString()); + log.info("Gemini 계정별 사용량 리포트 전송 완료"); + } + + + /** + * 매일 오후 11시 59분에 일일 토큰 사용량 리포트 전송 + */ + @Scheduled(cron = "0 59 23 * * *") + public void sendDailyTokenReport() { + String usageInfo = tokenTracker.getCurrentUsageInfo(); + log.info("⏰ 현재 토큰 사용량: {}", usageInfo); + tokenTracker.sendDailyTokenReport(); + } + + /** + * 매주 월요일 오전 9시에 주간 토큰 사용량 요약 리포트 전송 + */ + @Scheduled(cron = "0 0 9 * * MON") + public void sendWeeklyTokenSummary() { + tokenTracker.sendWeeklyTokenReport(); + } +} diff --git a/src/main/java/book/book/quiz/scheduler/TokenUsageScheduler.java b/src/main/java/book/book/quiz/scheduler/TokenUsageScheduler.java deleted file mode 100644 index 96e41606..00000000 --- a/src/main/java/book/book/quiz/scheduler/TokenUsageScheduler.java +++ /dev/null @@ -1,36 +0,0 @@ -package book.book.quiz.scheduler; - -import book.book.quiz.service.GeminiTokenTracker; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -/** - * 토큰 사용량 관련 스케줄러 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class TokenUsageScheduler { - - private final GeminiTokenTracker tokenTracker; - - /** - * 매일 오후 11시 59분에 일일 토큰 사용량 리포트 전송 - */ - @Scheduled(cron = "0 59 23 * * *") - public void sendDailyTokenReport() { - String usageInfo = tokenTracker.getCurrentUsageInfo(); - log.info("⏰ 현재 토큰 사용량: {}", usageInfo); - tokenTracker.sendDailyTokenReport(); - } - - /** - * 매주 월요일 오전 9시에 주간 토큰 사용량 요약 리포트 전송 - */ - @Scheduled(cron = "0 0 9 * * MON") - public void sendWeeklyTokenSummary() { - tokenTracker.sendWeeklyTokenReport(); - } -} diff --git a/src/main/java/book/book/quiz/service/GeminiTokenTracker.java b/src/main/java/book/book/quiz/service/GeminiTokenTracker.java index 7a6457bc..3034c15f 100644 --- a/src/main/java/book/book/quiz/service/GeminiTokenTracker.java +++ b/src/main/java/book/book/quiz/service/GeminiTokenTracker.java @@ -21,16 +21,43 @@ public class GeminiTokenTracker { // 일일 한도 설정 private static final int DAILY_TOKEN_LIMIT = 1000000; // 100만 토큰 - private static final int WARNING_THRESHOLD = 800000; // 80만 토큰에서 경고 + private static final int WARNING_THRESHOLD = 800000; // 80만 토큰에서 경고 // Gemini 2.5 Pro 가격 설정 (per 1K tokens) // Short context (prompts <= 200k tokens) - private static final double INPUT_COST_PER_1K = 0.00125; // $1.25 per 1M tokens - private static final double OUTPUT_COST_PER_1K = 0.01; // $10.00 per 1M tokens + private static final double INPUT_COST_PER_1K = 0.00125; // $1.25 per 1M tokens + private static final double OUTPUT_COST_PER_1K = 0.01; // $10.00 per 1M tokens // Long context (prompts > 200k tokens) - private static final double INPUT_COST_PER_1K_LONG = 0.0025; // $2.50 per 1M tokens - private static final double OUTPUT_COST_PER_1K_LONG = 0.015; // $15.00 per 1M tokens + private static final double INPUT_COST_PER_1K_LONG = 0.0025; // $2.50 per 1M tokens + private static final double OUTPUT_COST_PER_1K_LONG = 0.015; // $15.00 per 1M tokens + + private static final String MODEL = "gemini-2.5-flash"; + + /** + * 토큰 사용량 기록 (Response에서 추출) + */ + public void recordTokenUsage(String accountName, com.google.genai.types.GenerateContentResponse response) { + try { + var usageMetadata = response.usageMetadata() + .orElseThrow(() -> new IllegalStateException("UsageMetadata missing in response")); + + int inputTokens = usageMetadata.promptTokenCount().orElse(0); + int outputTokens = usageMetadata.candidatesTokenCount().orElse(0); + + GeminiTokenUsage tokenUsage = GeminiTokenUsage.from( + inputTokens, outputTokens, MODEL, "general_usage"); + + recordTokenUsage(tokenUsage); + + log.debug("토큰 사용량 기록: account={}, input={}, output={}, total={}", + accountName, inputTokens, outputTokens, inputTokens + outputTokens); + + } catch (Exception e) { + log.error("토큰 사용량 기록 실패: {}", e.getMessage(), e); + throw e; + } + } /** * 토큰 사용량 기록 @@ -59,8 +86,8 @@ private void logTokenUsage(GeminiTokenUsage tokenUsage, LocalDate today) { String logMessage = String.format( "📊 Gemini API Token Usage - Operation: %s, Model: %s, " + - "Input: %d tokens, Output: %d tokens, Total: %d tokens, Cost: $%.6f, " + - "Daily Total: Input=%d, Output=%d, Total=%d, Calls=%d, Usage: %.1f%%", + "Input: %d tokens, Output: %d tokens, Total: %d tokens, Cost: $%.6f, " + + "Daily Total: Input=%d, Output=%d, Total=%d, Calls=%d, Usage: %.1f%%", tokenUsage.getOperation(), tokenUsage.getModel(), tokenUsage.getInputTokens(), @@ -71,8 +98,7 @@ private void logTokenUsage(GeminiTokenUsage tokenUsage, LocalDate today) { todayOutputTotal, todayTotal, todayCallTotal, - usagePercentage - ); + usagePercentage); log.info(logMessage); } @@ -188,8 +214,7 @@ public void sendWeeklyTokenReport() { totalApiCalls, usagePercentage, (int) weeklyLimit, - estimatedCost - ); + estimatedCost); } catch (Exception e) { log.error("주간 토큰 리포트 전송 실패", e); diff --git a/src/main/java/book/book/quiz/service/QuizAdminService.java b/src/main/java/book/book/quiz/service/QuizAdminService.java index d4fde488..4132b2c7 100644 --- a/src/main/java/book/book/quiz/service/QuizAdminService.java +++ b/src/main/java/book/book/quiz/service/QuizAdminService.java @@ -56,25 +56,20 @@ public void deleteQuizzesByBookId(Long bookId) { .map(Chapter::getId) .toList(); - // 1. 퀴즈 ID 조회 List quizIds = quizRepository.findByChapterIdIn(chapterIds).stream() .map(Quiz::getId) .toList(); - // 2. 퀴즈 선택지 삭제 if (!quizIds.isEmpty()) { quizChoiceRepository.deleteByQuizIdIn(quizIds); } - // 3. 퀴즈 삭제 quizRepository.deleteByChapterIdIn(chapterIds); - // 2. 챕터 상태 업데이트 chapters.forEach(Chapter::resetQuizAvailable); - // 3. 책 상태 업데이트 book.updateGeneratedQuizCount(0); - book.updateQuizStatus(null); // 초기화 + book.updateQuizStatus(null); } @Transactional(readOnly = true) diff --git a/src/main/java/book/book/quiz/service/QuizAlertService.java b/src/main/java/book/book/quiz/service/QuizAlertService.java index daa7e140..3eff2834 100644 --- a/src/main/java/book/book/quiz/service/QuizAlertService.java +++ b/src/main/java/book/book/quiz/service/QuizAlertService.java @@ -155,6 +155,25 @@ public void sendGeminiWeeklyTokenReport(String weekRange, int inputTokens, int o } } + /** + * 계정별 사용량 리포트 전송 (Hourly) + */ + public void sendAccountUsageReport(String reportContent) { + try { + String message = String.format( + "📊 **Gemini 계정별 사용량 리포트**\n" + + "- Time: %s\n\n" + + "%s", + LocalDateTime.now().format(FORMATTER), + reportContent); + + senderToDiscord.sendLog(DiscordChannelType.GEMINI_API, "📊 계정별 사용량 (Hourly)", message, 0x00FF00); + + } catch (Exception e) { + log.error("계정별 사용량 리포트 전송 실패", e); + } + } + /** * Exception 타입에 따른 자동 알림 처리 */ @@ -162,16 +181,14 @@ public void sendGeminiWeeklyTokenReport(String weekRange, int inputTokens, int o /** * 퀴즈 생성 전체 실패 알림 */ - public void notifyQuizGenerationFailure(Long bookId, String bookTitle, Throwable ex) { + public void notifyQuizGenerationFailure(Long bookId, Throwable ex) { try { String message = String.format( "🚨 **퀴즈 생성 실패 (전체)**\n" + "- BookId: %d\n" + - "- 책 제목: %s\n" + "- Error: %s\n" + "- Time: %s", bookId, - bookTitle, ex.getMessage(), LocalDateTime.now().format(FORMATTER)); @@ -182,14 +199,4 @@ public void notifyQuizGenerationFailure(Long bookId, String bookTitle, Throwable log.error("디스코드 알림 전송 실패: error={}", e.getMessage()); } } - - public void handleException(Long contextId, Exception e) { - if (e instanceof CallNotPermittedException) { - notifyCircuitBreakerOpen("Gemini API"); - } else if (e.getMessage() != null && e.getMessage().contains("quota")) { - log.error("Quota 경고 에러 발생"); - } else { - notifyAiApiFailure(contextId, e); - } - } } diff --git a/src/main/java/book/book/quiz/service/QuizGenerationAsyncService.java b/src/main/java/book/book/quiz/service/QuizGenerationAsyncService.java index d3e7198c..0f9849e0 100644 --- a/src/main/java/book/book/quiz/service/QuizGenerationAsyncService.java +++ b/src/main/java/book/book/quiz/service/QuizGenerationAsyncService.java @@ -3,8 +3,11 @@ import book.book.book.entity.Book; import book.book.book.entity.Chapter; 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.GeminiSdkClient; +import book.book.quiz.external.gemini.GeminiApiService; +import book.book.quiz.external.gemini.prompt.GeminiRequestFactory; import java.util.List; import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; @@ -22,10 +25,11 @@ @RequiredArgsConstructor public class QuizGenerationAsyncService { - private final GeminiSdkClient geminiSdkClient; + private final GeminiApiService geminiApiService; private final QuizSaveService quizSaveService; private final ApplicationEventPublisher eventPublisher; private final QuizAlertService quizAlertService; + private final GeminiRequestFactory geminiRequestFactory; /** * 백그라운드에서 퀴즈 생성 @@ -35,6 +39,9 @@ public CompletableFuture generateQuizzes(Book book, List chapters return generateQuizzesInternal(book, chapters, memberId); } + /** + * 관리자는 memberId 없이 퀴즈 생성 + */ @Async public CompletableFuture generateQuizzes(Book book, List chapters) { return generateQuizzesInternal(book, chapters, null); @@ -43,25 +50,24 @@ public CompletableFuture generateQuizzes(Book book, List chapters private CompletableFuture generateQuizzesInternal(Book book, List chapters, Long memberId) { quizSaveService.updateQuizStatus(book.getId(), QuizStatus.PROCESSING); - // Gemini 비동기 API 호출 - return geminiSdkClient.generateBatchQuizzesAsync( - book.getTitle(), - book.getAuthor(), - chapters).thenAccept(batchResponse -> { - // 퀴즈 저장 및 상태 업데이트 (트랜잭션 분리) + GeminiRequest request = geminiRequestFactory.createBatchQuizRequest(book, chapters); + + return geminiApiService.generateContentAsync(request) + .thenAccept(batchResponse -> { List chapterIds = chapters.stream().map(Chapter::getId).toList(); - quizSaveService.saveQuizzesBatchAndUpdateStatus(book.getId(), chapterIds, batchResponse); - // 퀴즈 생성 완료 이벤트 발행 (memberId가 있을 경우에만) + quizSaveService.saveQuizzesBatchAndUpdateStatus( + book.getId(), + chapterIds, + batchResponse); + if (memberId != null) { - eventPublisher.publishEvent(QuizCreatedEvent.of(book.getId(), memberId)); + eventPublisher.publishEvent(new QuizCreatedEvent(book.getId(), memberId)); } - }).exceptionally(e -> { - log.error("백그라운드 퀴즈 생성 실패: bookId={}, error={}", book.getId(), e.getMessage(), e); + }).exceptionally(ex -> { quizSaveService.updateQuizStatus(book.getId(), QuizStatus.FAILED); - // 디스코드 알림 전송 - quizAlertService.notifyQuizGenerationFailure(book.getId(), book.getTitle(), e); + quizAlertService.notifyQuizGenerationFailure(book.getId(), ex); 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 3fdd6a37..69f5b557 100644 --- a/src/main/java/book/book/quiz/service/QuizGenerationService.java +++ b/src/main/java/book/book/quiz/service/QuizGenerationService.java @@ -24,9 +24,10 @@ public class QuizGenerationService { private final QuizSaveService quizSaveService; /** - * 퀴즈 생성 요청 접수 (즉시 응답) + * 퀴즈 생성 요청 (비동기) + * - 이미 퀴즈가 있거나 생성 중이면 해당 상태 반환 + * - 없으면 비동기 생성 시작 후 PENDING 반환 */ - public QuizGenerationAcceptedResponse generateQuizIfAbsent(Long bookId, Long memberId) { Book book = bookRepository.findByIdOrElseThrow(bookId); List chapters = validateAndGetChapters(bookId); diff --git a/src/main/java/book/book/quiz/service/QuizSaveService.java b/src/main/java/book/book/quiz/service/QuizSaveService.java index fc837df1..3ce1d10a 100644 --- a/src/main/java/book/book/quiz/service/QuizSaveService.java +++ b/src/main/java/book/book/quiz/service/QuizSaveService.java @@ -12,7 +12,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -31,70 +30,64 @@ public class QuizSaveService { private final QuizRepository quizRepository; private final BookRepository bookRepository; private final ChapterRepository chapterRepository; - private final QuizAlertService quizAlertService; private final QuizService quizService; /** * 배치 퀴즈 저장 및 상태 업데이트 * - * 챕터와 퀴즈 응답을 유사도 기반으로 매칭합니다. * Gemini API가 일부 챕터(예: 작가의 말)에 대해 퀴즈를 생성하지 않을 수 있습니다. - * 중간 챕터에 퀴즈가 없으면 에러를 발생시킵니다. */ @Transactional public List saveQuizzesBatchAndUpdateStatus(Long bookId, List chapterIds, - GeminiQuizResponses batchResponse) { + GeminiQuizResponses batchResponse) { Book book = bookRepository.findByIdOrElseThrow(bookId); List chapters = chapterRepository.findAllById(chapterIds); - List quizResponses = batchResponse.getQuizzes(); - log.info("퀴즈 매칭 시작: book={}, 챕터 수={}, 퀴즈 응답 수={}", - book.getTitle(), chapters.size(), quizResponses.size()); + quizService.deleteQuizzesByBookId(bookId); + + List quizzes = matchAndConvertToQuizzes(chapters, batchResponse.getQuizzes()); + + List savedQuizzes = quizRepository.saveAll(quizzes); + + book.completeQuizGeneration(savedQuizzes.size()); + + log.info("퀴즈 생성 완료: Book[{}], 생성된 퀴즈[{}]개", book.getTitle(), savedQuizzes.size()); - // 챕터 ID로 맵핑 + return savedQuizzes; + } + + public List matchAndConvertToQuizzes(List chapters, List responses) { Map chapterMap = chapters.stream() .collect(Collectors.toMap(Chapter::getId, c -> c)); List quizzes = new ArrayList<>(); - List matchedChapters = new ArrayList<>(); - - quizService.deleteQuizzesByBookId(bookId); - // 각 퀴즈 응답을 해당 챕터와 매칭 - for (ChapterQuizResponse response : quizResponses) { + for (ChapterQuizResponse response : responses) { Long chapterId = response.getChapterId(); - - if (chapterId == null) { - log.warn("퀴즈 응답에 chapterId가 없음: title='{}'", response.getChapterTitle()); - continue; - } - Chapter chapter = chapterMap.get(chapterId); - if (chapter != null) { + + if (validateMatch(chapter, response)) { Quiz quiz = response.toQuizWithChoice(chapter); quizzes.add(quiz); - matchedChapters.add(chapter); - // 챕터에 퀴즈가 있다고 표시 chapter.markQuizAvailable(); - chapterRepository.save(chapter); log.info("퀴즈 매칭 성공: chapterId={}, title='{}'", chapter.getId(), chapter.getTitle()); - } else { - log.warn("퀴즈 응답의 chapterId에 해당하는 챕터를 찾을 수 없음: chapterId={}, title='{}'", - chapterId, response.getChapterTitle()); } } + return quizzes; + } - List savedQuizzes = quizRepository.saveAll(quizzes); - - // Book에 생성된 퀴즈 수 업데이트 - book.updateGeneratedQuizCount(savedQuizzes.size()); - book.updateQuizStatus(QuizStatus.COMPLETED); - bookRepository.save(book); - - log.info("배치 퀴즈 저장 완료: book={}, savedQuizCount={}/{}", - book.getTitle(), savedQuizzes.size(), chapters.size()); - return savedQuizzes; + private boolean validateMatch(Chapter chapter, ChapterQuizResponse response) { + if (response.getChapterId() == null) { + log.warn("퀴즈 응답에 chapterId 누락: title='{}'", response.getChapterTitle()); + return false; + } + if (chapter == null) { + log.warn("ID 불일치: 챕터를 찾을 수 없음 id={}, title='{}'", + response.getChapterId(), response.getChapterTitle()); + return false; + } + return true; } /** diff --git a/src/main/resources/redis/scripts/acquire_permit.lua b/src/main/resources/redis/scripts/acquire_permit.lua new file mode 100644 index 00000000..cb0d3315 --- /dev/null +++ b/src/main/resources/redis/scripts/acquire_permit.lua @@ -0,0 +1,44 @@ +-- KEYS[1]: Daily Key +-- KEYS[2]: Sliding Window Key +-- ARGV[1]: Daily Limit +-- ARGV[2]: RPM Limit +-- ARGV[3]: Current Timestamp (ms) +-- ARGV[4]: Window Duration (ms) +-- ARGV[5]: Seconds until midnight (for Daily Key TTL) + +local dailyKey = KEYS[1] +local windowKey = KEYS[2] +local dailyLimit = tonumber(ARGV[1]) +local rpmLimit = tonumber(ARGV[2]) +local currentTimestamp = tonumber(ARGV[3]) +local windowDuration = tonumber(ARGV[4]) +local ttlSeconds = tonumber(ARGV[5]) + +-- 1. Check Daily Count +local currentDailyCount = tonumber(redis.call('GET', dailyKey) or "0") +if currentDailyCount >= dailyLimit then + return 0 -- Daily Limit Exceeded +end + +-- 2. Check Sliding Window Count +local windowStart = currentTimestamp - windowDuration +-- Remove old entries +redis.call('ZREMRANGEBYSCORE', windowKey, '-inf', windowStart) +-- Count current entries +local currentWindowCount = redis.call('ZCARD', windowKey) + +if currentWindowCount >= rpmLimit then + return 0 -- RPM Limit Exceeded +end + +-- 3. Increment Daily Count +redis.call('INCR', dailyKey) +if currentDailyCount == 0 then + redis.call('EXPIRE', dailyKey, ttlSeconds) +end + +-- 4. Add to Sliding Window +redis.call('ZADD', windowKey, currentTimestamp, currentTimestamp) +redis.call('PEXPIRE', windowKey, windowDuration) -- Set TTL for window key + +return 1 -- Success diff --git a/src/test/java/book/book/bestseller/service/BestsellerServiceTest.java b/src/test/java/book/book/bestseller/service/BestsellerServiceTest.java index d818a62a..a09d28df 100644 --- a/src/test/java/book/book/bestseller/service/BestsellerServiceTest.java +++ b/src/test/java/book/book/bestseller/service/BestsellerServiceTest.java @@ -6,8 +6,6 @@ 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; @@ -17,8 +15,6 @@ import book.book.book.repository.BookRepository; import book.book.book.repository.ChapterRepository; 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; @@ -46,9 +42,6 @@ class BestsellerServiceTest { @Autowired private QuizGenerationAsyncService quizGenerationAsyncService; - @Autowired - private SenderToDiscord senderToDiscord; - @Test void 이미_존재하는_베스트셀러는_저장하지_않는다() { // given @@ -130,29 +123,4 @@ class BestsellerServiceTest { 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)); - } } diff --git a/src/test/java/book/book/config/TestExternalApiConfig.java b/src/test/java/book/book/config/TestExternalApiConfig.java index 3cdf45b2..96eed93b 100644 --- a/src/test/java/book/book/config/TestExternalApiConfig.java +++ b/src/test/java/book/book/config/TestExternalApiConfig.java @@ -5,7 +5,7 @@ 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.external.gemini.GeminiApiService; import book.book.quiz.service.QuizAlertService; import book.book.quiz.service.QuizGenerationAsyncService; import book.book.search.service.AladinService; @@ -16,12 +16,12 @@ /** * 통합 테스트에서 외부 API 의존성을 Mock으로 대체하는 설정 - * + *

* 목적: * 1. 외부 API 호출 방지 (Gemini AI, 알라딘 도서 검색, 알림 서비스 등) * 2. 모든 @IntegrationTest가 동일한 Spring Context를 공유 → 테스트 속도 대폭 향상 * 3. 테스트 안정성 확보 (외부 API 장애의 영향 제거) - * + *

* 주의: * - 기본적으로 DB는 Mock하지 않습니다 (H2 DB, Redis Testcontainer 사용) * - 새로운 외부 API 추가 시 이 파일에 Mock Bean 추가 필요 @@ -37,8 +37,8 @@ public class TestExternalApiConfig { */ @Bean @Primary - public GeminiSdkClient geminiSdkClient() { - return mock(GeminiSdkClient.class); + public GeminiApiService geminiApiService() { + return mock(GeminiApiService.class); } /** diff --git a/src/test/java/book/book/quiz/api/QuizControllerTest.java b/src/test/java/book/book/quiz/api/QuizControllerTest.java index 27556da6..65dca876 100644 --- a/src/test/java/book/book/quiz/api/QuizControllerTest.java +++ b/src/test/java/book/book/quiz/api/QuizControllerTest.java @@ -1,9 +1,6 @@ package book.book.quiz.api; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -23,7 +20,6 @@ import book.book.challenge.fixture.ReadingChallengeFixture; import book.book.challenge.repository.ReadingChallengeRepository; 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; @@ -37,15 +33,12 @@ import book.book.quiz.domain.QuizStatus; import book.book.quiz.dto.request.QuizErrorReportRequest; import book.book.quiz.dto.request.QuizSubmitRequest; -import book.book.quiz.external.GeminiSdkClient; -import book.book.quiz.fixture.GeminiQuizResponsesFixture; import book.book.quiz.fixture.QuizChoiceFixture; import book.book.quiz.fixture.QuizFixture; import book.book.quiz.repository.QuizAttemptRepository; import book.book.quiz.repository.QuizRepository; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.List; -import java.util.concurrent.CompletableFuture; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -56,515 +49,489 @@ @IntegrationTest class QuizControllerTest { - @Autowired - private MockMvc mockMvc; + @Autowired + private MockMvc mockMvc; - @Autowired - private QuizRepository quizRepository; + @Autowired + private QuizRepository quizRepository; - @Autowired - private QuizAttemptRepository quizAttemptRepository; - - @Autowired - private ChapterRepository chapterRepository; - - @Autowired - private BookRepository bookRepository; - - @Autowired - private BookCategoryRepository bookCategoryRepository; - - @Autowired - private MemberRepository memberRepository; - - @Autowired - private ObjectMapper objectMapper; - - @Autowired - private SecurityTestUtils securityTestUtils; - - @Autowired - private GeminiSdkClient geminiSdkClient; // TestExternalApiConfig에서 Mock 주입 - - @Autowired - private ReadingChallengeRepository readingChallengeRepository; - - @Autowired - private V2ReadingProgressRepository v2ReadingProgressRepository; - - @Autowired - private V2ChallengeService v2ChallengeService; - - @Autowired - private SenderToDiscord senderToDiscord; - - private Member member; - private Book book; - private Chapter chapter; - private Quiz quiz; - private Long quizId; - private QuizChoice correctChoice; - private QuizChoice wrongChoice; - private RequestPostProcessor mockUser; - - @BeforeEach - void setUp() { - member = memberRepository.save(MemberFixture.createWithoutId()); - - BookCategory bookCategory = bookCategoryRepository.save(new BookCategory("소설", null)); - book = bookRepository.save(BookFixture.builder().category(bookCategory).build()); - - chapter = chapterRepository.save(ChapterFixture.builderWithoutId() - .book(book) - .build()); - - quiz = QuizFixture.builderWithoutId() - .chapter(chapter) - .build(); - - correctChoice = QuizChoiceFixture.builderWithoutId() - .isCorrect(true) - .choiceOrder(1) - .build(); - - wrongChoice = QuizChoiceFixture.builderWithoutId() - .isCorrect(false) - .choiceOrder(2) - .build(); - - quiz.addChoice(correctChoice); - quiz.addChoice(wrongChoice); - - quiz = quizRepository.save(quiz); - quizId = quiz.getId(); - - // 기본 챌린지 및 진행상황 설정 (퀴즈 제출에 필요) - ReadingChallenge challenge = readingChallengeRepository.save( - ReadingChallengeFixture.builderWithoutId() - .member(member) - .book(book) - .build()); - - v2ReadingProgressRepository.save( - V2ReadingProgress.builder() - .readingChallenge(challenge) - .chapterId(chapter.getId()) - .status(ChapterStatus.PROCESSING) - .build()); - - mockUser = securityTestUtils.mockUser(member); - } - - @Test - void 챕터_퀴즈_목록을_조회할_수_있다() throws Exception { - // given - List chapterIds = List.of(chapter.getId()); - - // when & then - mockMvc.perform(post("/api/v3/chapters/quizzes") - .with(mockUser) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(chapterIds))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.chapters").isArray()) - .andExpect(jsonPath("$.data.chapters[0].chapterId").value(chapter.getId())) - .andExpect(jsonPath("$.data.chapters[0].question").value(quiz.getQuestion())); - } - - @Test - void 존재하지_않는_챕터_ID로_조회_시_404를_반환한다() throws Exception { - // given - List nonExistentChapterIds = List.of(99999L); - - // when & then - mockMvc.perform(post("/api/v3/chapters/quizzes") - .with(mockUser) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(nonExistentChapterIds))) - .andDo(print()) - .andExpect(status().isNotFound()); - } - - @Test - void 퀴즈_답안을_제출할_수_있다_정답() throws Exception { - // given - QuizSubmitRequest request = new QuizSubmitRequest(); - request.setChoiceId(correctChoice.getId()); - - // when & then - mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) - .with(mockUser) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.isCorrect").value(true)) - .andExpect(jsonPath("$.data.correctChoiceId").value(correctChoice.getId())) - .andExpect(jsonPath("$.data.isChallengeCompleted").value(false)); - } - - @Test - void 퀴즈_답안을_제출할_수_있다_오답() throws Exception { - // given - QuizSubmitRequest request = new QuizSubmitRequest(); - request.setChoiceId(wrongChoice.getId()); - - // when & then - mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) - .with(mockUser) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.isCorrect").value(false)) - .andExpect(jsonPath("$.data.correctChoiceId").value(correctChoice.getId())) - .andExpect(jsonPath("$.data.isChallengeCompleted").value(false)); - } - - @Test - void 퀴즈_답안_제출_시_QuizAttempt가_저장된다() throws Exception { - // given - QuizSubmitRequest request = new QuizSubmitRequest(); - request.setChoiceId(correctChoice.getId()); - - // when - mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) - .with(mockUser) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()); - - // then - assertThat(quizAttemptRepository.existsByMemberIdAndQuizId(member.getId(), quizId)) - .isTrue(); - } - - @Test - void 퀴즈_답안_제출_시_통계가_업데이트된다() throws Exception { - // given - QuizSubmitRequest request = new QuizSubmitRequest(); - request.setChoiceId(correctChoice.getId()); - int initialAttemptCount = quiz.getAttemptCount(); - int initialCorrectCount = quiz.getCorrectCount(); - - // when - mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) - .with(mockUser) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()); - - // then - Quiz updatedQuiz = quizRepository.findById(quizId).orElseThrow(); - assertThat(updatedQuiz.getAttemptCount()).isEqualTo(initialAttemptCount + 1); - assertThat(updatedQuiz.getCorrectCount()).isEqualTo(initialCorrectCount + 1); - } - - @Test - void 중복_제출_시_409을_반환한다() throws Exception { - // given - QuizSubmitRequest request = new QuizSubmitRequest(); - request.setChoiceId(correctChoice.getId()); - - // 첫 번째 제출 - mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) - .with(mockUser) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()); - - // when & then - 두 번째 제출 - mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) - .with(mockUser) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isConflict()); // 409 Conflict - } - - @Test - void 존재하지_않는_선택지로_제출_시_404을_반환한다() throws Exception { - // given - QuizSubmitRequest request = new QuizSubmitRequest(); - request.setChoiceId(99999L); - - // when & then - mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) - .with(mockUser) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isNotFound()); // 404 - QUIZ_CHOICE_NOT_FOUND - } - - @Test - void 퀴즈_답안을_제출하면_성공_응답을_받는다() throws Exception { - // given - QuizSubmitRequest request = new QuizSubmitRequest(); - request.setChoiceId(correctChoice.getId()); - - // when & then - mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) - .with(mockUser) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.isCorrect").value(true)) - .andExpect(jsonPath("$.data.correctChoiceId").value(correctChoice.getId())) - .andExpect(jsonPath("$.data.isChallengeCompleted").value(false)); - } - - @Test - void 책의_모든_챕터_퀴즈를_일괄_생성할_수_있다() throws Exception { - // given - BookCategory bookCategory = bookCategoryRepository.save(new BookCategory("소설", null)); - Book book2 = bookRepository.save(BookFixture.builder().category(bookCategory).build()); - Chapter chapter1 = chapterRepository.save(ChapterFixture.builderWithoutId() - .book(book2) - .title("챕터 1") - .build()); - Chapter chapter2 = chapterRepository.save(ChapterFixture.builderWithoutId() - .book(book2) - .title("챕터 2") - .build()); - - // 비동기 API 호출 Mock 설정 - given(geminiSdkClient.generateBatchQuizzesAsync(anyString(), anyString(), any())) - .willReturn(CompletableFuture.completedFuture( - GeminiQuizResponsesFixture.createWithChapters( - List.of(chapter, chapter1, chapter2)))); - - // when & then - 비동기 처리 응답 검증 - mockMvc.perform(post("/api/v1/admin/quizzes/books/{bookId}/quizzes/generate-all", book2.getId()) - .with(mockUser) - .contentType(MediaType.APPLICATION_JSON)) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.status").value("PENDING")) - .andExpect(jsonPath("$.data.bookId").value(book2.getId())) - .andExpect(jsonPath("$.data.chapterCount").value(2)); - } - - @Test - void 이미_퀴즈가_있는_책은_ALREADY_EXISTS_상태를_반환한다() throws Exception { - // given - 퀴즈가 완료된 상태로 설정 - book.updateQuizStatus(QuizStatus.COMPLETED); - bookRepository.save(book); - - // when & then - mockMvc.perform(post("/api/v1/admin/quizzes/books/{bookId}/quizzes/generate-all", book.getId()) - .with(mockUser) - .contentType(MediaType.APPLICATION_JSON)) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.status").value("ALREADY_EXISTS")) - .andExpect(jsonPath("$.data.bookId").value(book.getId())) - .andExpect(jsonPath("$.data.chapterCount").value(1)); - } - - @Test - void 챕터가_없는_책은_404를_반환한다() throws Exception { - // given - BookCategory bookCategory = bookCategoryRepository.save(new BookCategory("소설", null)); - Book bookWithoutChapters = bookRepository.save(BookFixture.builder().category(bookCategory).build()); - - // when & then - mockMvc.perform(post("/api/v1/admin/quizzes/books/{bookId}/quizzes/generate-all", - bookWithoutChapters.getId()) - .with(mockUser) - .contentType(MediaType.APPLICATION_JSON)) - .andDo(print()) - .andExpect(status().isNotFound()); - } - - // ===== 챌린지 완료 검증 테스트 ===== - - @Test - void 퀴즈_정답_제출_시_챕터가_완료_상태로_변경된다() throws Exception { - // given: setUp()에서 생성된 챌린지와 진행상황 사용 - QuizSubmitRequest request = new QuizSubmitRequest(); - request.setChoiceId(correctChoice.getId()); - - // when: 퀴즈 정답 제출 - mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) - .with(mockUser) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.isChallengeCompleted").value(false)); - - // then: 챕터가 완료 상태로 변경되어야 함 - List progresses = v2ReadingProgressRepository.findAll(); - assertThat(progresses).hasSize(1); - assertThat(progresses.get(0).getStatus()).isEqualTo(ChapterStatus.COMPLETED); - } - - @Test - void 마지막_챕터_퀴즈_정답_시_챌린지가_완료된다() throws Exception { - // given: 챕터 1개인 책과 챌린지 생성 - BookCategory category = bookCategoryRepository.save(new BookCategory("소설", null)); - Book singleChapterBook = bookRepository.save( - BookFixture.builderWithoutId() - .category(category) - .chapterCount(1) - .generatedQuizCount(1) - .build()); - - Chapter singleChapter = chapterRepository.save( - ChapterFixture.builderWithoutId() - .book(singleChapterBook) - .build()); - - Quiz singleQuiz = QuizFixture.builderWithoutId() - .chapter(singleChapter) - .build(); - - QuizChoice correctChoiceForSingle = QuizChoiceFixture.builderWithoutId() - .isCorrect(true) - .build(); - singleQuiz.addChoice(correctChoiceForSingle); - singleQuiz = quizRepository.save(singleQuiz); - - ReadingChallenge challenge = readingChallengeRepository.save( - ReadingChallengeFixture.builderWithoutId() - .member(member) - .book(singleChapterBook) - .build()); - - V2ReadingProgress progress = v2ReadingProgressRepository.save( - V2ReadingProgress.builder() - .readingChallenge(challenge) - .chapterId(singleChapter.getId()) - .status(ChapterStatus.PROCESSING) - .build()); - - QuizSubmitRequest request = new QuizSubmitRequest(); - request.setChoiceId(correctChoiceForSingle.getId()); - - // when: 마지막 챕터 퀴즈 정답 제출 - mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", singleQuiz.getId()) - .with(mockUser) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.isChallengeCompleted").value(true)); - - // then: 챌린지가 완료되어야 함 - ReadingChallenge updatedChallenge = readingChallengeRepository.findById(challenge.getId()) - .orElseThrow(); - assertThat(updatedChallenge.isCompleted()).isTrue(); - assertThat(updatedChallenge.getCompletedAt()).isNotNull(); - } - - @Test - void 퀴즈_오답_제출_시에도_챕터가_완료된다() throws Exception { - // given: setUp()에서 생성된 챌린지와 진행상황 사용 - QuizSubmitRequest request = new QuizSubmitRequest(); - request.setChoiceId(wrongChoice.getId()); - - // when: 퀴즈 오답 제출 - mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) - .with(mockUser) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.isChallengeCompleted").value(false)); - - // then: 오답이어도 제출했으므로 챕터는 완료됨 (제출 유무가 중요) - List progresses = v2ReadingProgressRepository.findAll(); - assertThat(progresses).hasSize(1); - assertThat(progresses.get(0).getStatus()).isEqualTo(ChapterStatus.COMPLETED); - } - - @Test - void 퀴즈_오답_제출_시_마지막_챕터라면_챌린지도_완료된다() throws Exception { - // given: 챕터 1개인 책과 챌린지 생성 - BookCategory category = bookCategoryRepository.save(new BookCategory("소설", null)); - Book singleChapterBook = bookRepository.save( - BookFixture.builderWithoutId() - .category(category) - .chapterCount(1) - .generatedQuizCount(1) - .build()); - - Chapter singleChapter = chapterRepository.save( - ChapterFixture.builderWithoutId() - .book(singleChapterBook) - .build()); - - Quiz singleQuiz = QuizFixture.builderWithoutId() - .chapter(singleChapter) - .build(); - - QuizChoice correctChoiceForSingle = QuizChoiceFixture.builderWithoutId() - .isCorrect(true) - .choiceOrder(1) - .build(); - QuizChoice wrongChoiceForSingle = QuizChoiceFixture.builderWithoutId() - .isCorrect(false) - .choiceOrder(2) - .build(); - singleQuiz.addChoice(correctChoiceForSingle); - singleQuiz.addChoice(wrongChoiceForSingle); - singleQuiz = quizRepository.save(singleQuiz); - - ReadingChallenge challenge = readingChallengeRepository.save( - ReadingChallengeFixture.builderWithoutId() - .member(member) - .book(singleChapterBook) - .build()); - - V2ReadingProgress progress = v2ReadingProgressRepository.save( - V2ReadingProgress.builder() - .readingChallenge(challenge) - .chapterId(singleChapter.getId()) - .status(ChapterStatus.PROCESSING) - .build()); - - QuizSubmitRequest request = new QuizSubmitRequest(); - request.setChoiceId(wrongChoiceForSingle.getId()); - - // when: 마지막 챕터 퀴즈 오답 제출 - mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", singleQuiz.getId()) - .with(mockUser) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.isChallengeCompleted").value(true)); - - // then: 오답이어도 제출했으므로 챕터와 챌린지 모두 완료됨 - V2ReadingProgress updatedProgress = v2ReadingProgressRepository.findById(progress.getId()) - .orElseThrow(); - assertThat(updatedProgress.getStatus()).isEqualTo(ChapterStatus.COMPLETED); - - ReadingChallenge updatedChallenge = readingChallengeRepository.findById(challenge.getId()) - .orElseThrow(); - assertThat(updatedChallenge.isCompleted()).isTrue(); - assertThat(updatedChallenge.getCompletedAt()).isNotNull(); - } - - @Test - void 퀴즈_오류를_신고할_수_있다() throws Exception { - // given - QuizErrorReportRequest request = new QuizErrorReportRequest( - QuizErrorType.DIFFERENT_FROM_BOOK, - "이 문제는 이상합니다."); - - // when & then - mockMvc.perform(post("/api/v3/quizzes/{quizId}/error-report", quizId) - .with(mockUser) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()); - - // verify - org.mockito.Mockito.verify(senderToDiscord).sendLog( - org.mockito.ArgumentMatchers.eq(DiscordChannelType.QUIZ_FEEDBACK), - org.mockito.ArgumentMatchers.eq("퀴즈 오류 신고"), - org.mockito.ArgumentMatchers.contains("이 문제는 이상합니다."), - org.mockito.ArgumentMatchers.eq(16711680)); - } + @Autowired + private QuizAttemptRepository quizAttemptRepository; + + @Autowired + private ChapterRepository chapterRepository; + + @Autowired + private BookRepository bookRepository; + + @Autowired + private BookCategoryRepository bookCategoryRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private SecurityTestUtils securityTestUtils; + + @Autowired + private ReadingChallengeRepository readingChallengeRepository; + + @Autowired + private V2ReadingProgressRepository v2ReadingProgressRepository; + + @Autowired + private SenderToDiscord senderToDiscord; + + private Member member; + private Book book; + private Chapter chapter; + private Quiz quiz; + private Long quizId; + private QuizChoice correctChoice; + private QuizChoice wrongChoice; + private RequestPostProcessor mockUser; + + @BeforeEach + void setUp() { + member = memberRepository.save(MemberFixture.createWithoutId()); + + BookCategory bookCategory = bookCategoryRepository.save(new BookCategory("소설", null)); + book = bookRepository.save(BookFixture.builder().category(bookCategory).build()); + + chapter = chapterRepository.save(ChapterFixture.builderWithoutId() + .book(book) + .build()); + + quiz = QuizFixture.builderWithoutId() + .chapter(chapter) + .build(); + + correctChoice = QuizChoiceFixture.builderWithoutId() + .isCorrect(true) + .choiceOrder(1) + .build(); + + wrongChoice = QuizChoiceFixture.builderWithoutId() + .isCorrect(false) + .choiceOrder(2) + .build(); + + quiz.addChoice(correctChoice); + quiz.addChoice(wrongChoice); + + quiz = quizRepository.save(quiz); + quizId = quiz.getId(); + + // 기본 챌린지 및 진행상황 설정 (퀴즈 제출에 필요) + ReadingChallenge challenge = readingChallengeRepository.save( + ReadingChallengeFixture.builderWithoutId() + .member(member) + .book(book) + .build()); + + v2ReadingProgressRepository.save( + V2ReadingProgress.builder() + .readingChallenge(challenge) + .chapterId(chapter.getId()) + .status(ChapterStatus.PROCESSING) + .build()); + + mockUser = securityTestUtils.mockUser(member); + } + + @Test + void 챕터_퀴즈_목록을_조회할_수_있다() throws Exception { + // given + List chapterIds = List.of(chapter.getId()); + + // when & then + mockMvc.perform(post("/api/v3/chapters/quizzes") + .with(mockUser) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(chapterIds))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.chapters").isArray()) + .andExpect(jsonPath("$.data.chapters[0].chapterId").value(chapter.getId())) + .andExpect(jsonPath("$.data.chapters[0].question").value(quiz.getQuestion())); + } + + @Test + void 존재하지_않는_챕터_ID로_조회_시_404를_반환한다() throws Exception { + // given + List nonExistentChapterIds = List.of(99999L); + + // when & then + mockMvc.perform(post("/api/v3/chapters/quizzes") + .with(mockUser) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(nonExistentChapterIds))) + .andDo(print()) + .andExpect(status().isNotFound()); + } + + @Test + void 퀴즈_답안을_제출할_수_있다_정답() throws Exception { + // given + QuizSubmitRequest request = new QuizSubmitRequest(); + request.setChoiceId(correctChoice.getId()); + + // when & then + mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) + .with(mockUser) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isCorrect").value(true)) + .andExpect(jsonPath("$.data.correctChoiceId").value(correctChoice.getId())) + .andExpect(jsonPath("$.data.isChallengeCompleted").value(false)); + } + + @Test + void 퀴즈_답안을_제출할_수_있다_오답() throws Exception { + // given + QuizSubmitRequest request = new QuizSubmitRequest(); + request.setChoiceId(wrongChoice.getId()); + + // when & then + mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) + .with(mockUser) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isCorrect").value(false)) + .andExpect(jsonPath("$.data.correctChoiceId").value(correctChoice.getId())) + .andExpect(jsonPath("$.data.isChallengeCompleted").value(false)); + } + + @Test + void 퀴즈_답안_제출_시_QuizAttempt가_저장된다() throws Exception { + // given + QuizSubmitRequest request = new QuizSubmitRequest(); + request.setChoiceId(correctChoice.getId()); + + // when + mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) + .with(mockUser) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // then + assertThat(quizAttemptRepository.existsByMemberIdAndQuizId(member.getId(), quizId)) + .isTrue(); + } + + @Test + void 퀴즈_답안_제출_시_통계가_업데이트된다() throws Exception { + // given + QuizSubmitRequest request = new QuizSubmitRequest(); + request.setChoiceId(correctChoice.getId()); + int initialAttemptCount = quiz.getAttemptCount(); + int initialCorrectCount = quiz.getCorrectCount(); + + // when + mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) + .with(mockUser) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // then + Quiz updatedQuiz = quizRepository.findById(quizId).orElseThrow(); + assertThat(updatedQuiz.getAttemptCount()).isEqualTo(initialAttemptCount + 1); + assertThat(updatedQuiz.getCorrectCount()).isEqualTo(initialCorrectCount + 1); + } + + @Test + void 중복_제출_시_409을_반환한다() throws Exception { + // given + QuizSubmitRequest request = new QuizSubmitRequest(); + request.setChoiceId(correctChoice.getId()); + + // 첫 번째 제출 + mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) + .with(mockUser) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // when & then - 두 번째 제출 + mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) + .with(mockUser) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isConflict()); // 409 Conflict + } + + @Test + void 존재하지_않는_선택지로_제출_시_404을_반환한다() throws Exception { + // given + QuizSubmitRequest request = new QuizSubmitRequest(); + request.setChoiceId(99999L); + + // when & then + mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) + .with(mockUser) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isNotFound()); // 404 - QUIZ_CHOICE_NOT_FOUND + } + + @Test + void 퀴즈_답안을_제출하면_성공_응답을_받는다() throws Exception { + // given + QuizSubmitRequest request = new QuizSubmitRequest(); + request.setChoiceId(correctChoice.getId()); + + // when & then + mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) + .with(mockUser) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isCorrect").value(true)) + .andExpect(jsonPath("$.data.correctChoiceId").value(correctChoice.getId())) + .andExpect(jsonPath("$.data.isChallengeCompleted").value(false)); + } + + @Test + void 책의_모든_챕터_퀴즈를_일괄_생성할_수_있다() throws Exception { + // given + BookCategory bookCategory = bookCategoryRepository.save(new BookCategory("소설", null)); + Book book2 = bookRepository.save(BookFixture.builder().category(bookCategory).build()); + Chapter chapter1 = chapterRepository.save(ChapterFixture.builderWithoutId() + .book(book2) + .title("챕터 1") + .build()); + Chapter chapter2 = chapterRepository.save(ChapterFixture.builderWithoutId() + .book(book2) + .title("챕터 2") + .build()); + + // 비동기 API 호출 Mock 설정 (QuizGenerationAsyncService가 Mock이므로 실제 GeminiSdkClient는 + // 호출되지 않음) + + // when & then - 비동기 처리 응답 검증 + mockMvc.perform(post("/api/v1/admin/quizzes/books/{bookId}/quizzes/generate-all", book2.getId()) + .with(mockUser) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.bookId").value(book2.getId())) + .andExpect(jsonPath("$.data.chapterCount").value(2)); + } + + @Test + void 챕터가_없는_책은_404를_반환한다() throws Exception { + // given + BookCategory bookCategory = bookCategoryRepository.save(new BookCategory("소설", null)); + Book bookWithoutChapters = bookRepository.save(BookFixture.builder().category(bookCategory).build()); + + // when & then + mockMvc.perform(post("/api/v1/admin/quizzes/books/{bookId}/quizzes/generate-all", + bookWithoutChapters.getId()) + .with(mockUser) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isNotFound()); + } + + // ===== 챌린지 완료 검증 테스트 ===== + + @Test + void 퀴즈_정답_제출_시_챕터가_완료_상태로_변경된다() throws Exception { + // given: setUp()에서 생성된 챌린지와 진행상황 사용 + QuizSubmitRequest request = new QuizSubmitRequest(); + request.setChoiceId(correctChoice.getId()); + + // when: 퀴즈 정답 제출 + mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) + .with(mockUser) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isChallengeCompleted").value(false)); + + // then: 챕터가 완료 상태로 변경되어야 함 + List progresses = v2ReadingProgressRepository.findAll(); + assertThat(progresses).hasSize(1); + assertThat(progresses.get(0).getStatus()).isEqualTo(ChapterStatus.COMPLETED); + } + + @Test + void 마지막_챕터_퀴즈_정답_시_챌린지가_완료된다() throws Exception { + // given: 챕터 1개인 책과 챌린지 생성 + BookCategory category = bookCategoryRepository.save(new BookCategory("소설", null)); + Book singleChapterBook = bookRepository.save( + BookFixture.builderWithoutId() + .category(category) + .chapterCount(1) + .generatedQuizCount(1) + .build()); + + Chapter singleChapter = chapterRepository.save( + ChapterFixture.builderWithoutId() + .book(singleChapterBook) + .build()); + + Quiz singleQuiz = QuizFixture.builderWithoutId() + .chapter(singleChapter) + .build(); + + QuizChoice correctChoiceForSingle = QuizChoiceFixture.builderWithoutId() + .isCorrect(true) + .build(); + singleQuiz.addChoice(correctChoiceForSingle); + singleQuiz = quizRepository.save(singleQuiz); + + ReadingChallenge challenge = readingChallengeRepository.save( + ReadingChallengeFixture.builderWithoutId() + .member(member) + .book(singleChapterBook) + .build()); + + V2ReadingProgress progress = v2ReadingProgressRepository.save( + V2ReadingProgress.builder() + .readingChallenge(challenge) + .chapterId(singleChapter.getId()) + .status(ChapterStatus.PROCESSING) + .build()); + + QuizSubmitRequest request = new QuizSubmitRequest(); + request.setChoiceId(correctChoiceForSingle.getId()); + + // when: 마지막 챕터 퀴즈 정답 제출 + mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", singleQuiz.getId()) + .with(mockUser) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isChallengeCompleted").value(true)); + + // then: 챌린지가 완료되어야 함 + ReadingChallenge updatedChallenge = readingChallengeRepository.findById(challenge.getId()) + .orElseThrow(); + assertThat(updatedChallenge.isCompleted()).isTrue(); + assertThat(updatedChallenge.getCompletedAt()).isNotNull(); + } + + @Test + void 퀴즈_오답_제출_시에도_챕터가_완료된다() throws Exception { + // given: setUp()에서 생성된 챌린지와 진행상황 사용 + QuizSubmitRequest request = new QuizSubmitRequest(); + request.setChoiceId(wrongChoice.getId()); + + // when: 퀴즈 오답 제출 + mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", quizId) + .with(mockUser) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isChallengeCompleted").value(false)); + + // then: 오답이어도 제출했으므로 챕터는 완료됨 (제출 유무가 중요) + List progresses = v2ReadingProgressRepository.findAll(); + assertThat(progresses).hasSize(1); + assertThat(progresses.get(0).getStatus()).isEqualTo(ChapterStatus.COMPLETED); + } + + @Test + void 퀴즈_오답_제출_시_마지막_챕터라면_챌린지도_완료된다() throws Exception { + // given: 챕터 1개인 책과 챌린지 생성 + BookCategory category = bookCategoryRepository.save(new BookCategory("소설", null)); + Book singleChapterBook = bookRepository.save( + BookFixture.builderWithoutId() + .category(category) + .chapterCount(1) + .generatedQuizCount(1) + .build()); + + Chapter singleChapter = chapterRepository.save( + ChapterFixture.builderWithoutId() + .book(singleChapterBook) + .build()); + + Quiz singleQuiz = QuizFixture.builderWithoutId() + .chapter(singleChapter) + .build(); + + QuizChoice correctChoiceForSingle = QuizChoiceFixture.builderWithoutId() + .isCorrect(true) + .choiceOrder(1) + .build(); + QuizChoice wrongChoiceForSingle = QuizChoiceFixture.builderWithoutId() + .isCorrect(false) + .choiceOrder(2) + .build(); + singleQuiz.addChoice(correctChoiceForSingle); + singleQuiz.addChoice(wrongChoiceForSingle); + singleQuiz = quizRepository.save(singleQuiz); + + ReadingChallenge challenge = readingChallengeRepository.save( + ReadingChallengeFixture.builderWithoutId() + .member(member) + .book(singleChapterBook) + .build()); + + V2ReadingProgress progress = v2ReadingProgressRepository.save( + V2ReadingProgress.builder() + .readingChallenge(challenge) + .chapterId(singleChapter.getId()) + .status(ChapterStatus.PROCESSING) + .build()); + + QuizSubmitRequest request = new QuizSubmitRequest(); + request.setChoiceId(wrongChoiceForSingle.getId()); + + // when: 마지막 챕터 퀴즈 오답 제출 + mockMvc.perform(post("/api/v3/quizzes/{quizId}/submit", singleQuiz.getId()) + .with(mockUser) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isChallengeCompleted").value(true)); + + // then: 오답이어도 제출했으므로 챕터와 챌린지 모두 완료됨 + V2ReadingProgress updatedProgress = v2ReadingProgressRepository.findById(progress.getId()) + .orElseThrow(); + assertThat(updatedProgress.getStatus()).isEqualTo(ChapterStatus.COMPLETED); + + ReadingChallenge updatedChallenge = readingChallengeRepository.findById(challenge.getId()) + .orElseThrow(); + assertThat(updatedChallenge.isCompleted()).isTrue(); + assertThat(updatedChallenge.getCompletedAt()).isNotNull(); + } + + @Test + void 퀴즈_오류를_신고할_수_있다() throws Exception { + // given + QuizErrorReportRequest request = new QuizErrorReportRequest( + QuizErrorType.DIFFERENT_FROM_BOOK, + "이 문제는 이상합니다."); + + // when & then + mockMvc.perform(post("/api/v3/quizzes/{quizId}/error-report", quizId) + .with(mockUser) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()); + + // verify + org.mockito.Mockito.verify(senderToDiscord).sendLog( + org.mockito.ArgumentMatchers.eq(DiscordChannelType.QUIZ_FEEDBACK), + org.mockito.ArgumentMatchers.eq("퀴즈 오류 신고"), + org.mockito.ArgumentMatchers.contains("이 문제는 이상합니다."), + org.mockito.ArgumentMatchers.eq(16711680)); + } } diff --git a/src/test/java/book/book/quiz/external/GeminiClientManagerTest.java b/src/test/java/book/book/quiz/external/GeminiClientManagerTest.java index 8fdbd992..a6054c0a 100644 --- a/src/test/java/book/book/quiz/external/GeminiClientManagerTest.java +++ b/src/test/java/book/book/quiz/external/GeminiClientManagerTest.java @@ -2,78 +2,82 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; import book.book.common.CustomException; import book.book.common.ErrorCode; -import book.book.config.IntegrationTest; import book.book.quiz.config.GeminiAccountConfig; -import com.google.genai.Client; -import java.time.LocalDate; +import book.book.quiz.config.GeminiAccountConfig.Account; +import book.book.quiz.external.gemini.apikey.GeminiClientContext; +import book.book.quiz.external.gemini.apikey.GeminiClientManager; +import book.book.quiz.external.gemini.service.GeminiRateLimiterService; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.test.context.TestPropertySource; - -@IntegrationTest -@TestPropertySource(properties = { - "gemini.accounts[0].name=test-account-1", - "gemini.accounts[0].apiKey=fake-key-1", - "gemini.accounts[1].name=test-account-2", - "gemini.accounts[1].apiKey=fake-key-2", - "gemini.accounts[2].name=test-account-3", - "gemini.accounts[2].apiKey=fake-key-3", - "gemini.requestsPerMinute=600", - "gemini.maxCallsPerDay=50" -}) +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) class GeminiClientManagerTest { - @Autowired - private GeminiClientManager geminiClientManager; + @Mock + private GeminiAccountConfig accountConfig; - @Autowired - private RedisTemplate redisTemplate; + @Mock + private GeminiRateLimiterService rateLimiterService; - @Autowired - private GeminiAccountConfig geminiAccountConfig; + private GeminiClientManager geminiClientManager; @BeforeEach void setUp() { - // Redis 초기화 - redisTemplate.getConnectionFactory().getConnection().serverCommands().flushAll(); + // Mock Accounts + Account account1 = new Account(); + account1.setName("account-1"); + account1.setApiKey("key-1"); + + Account account2 = new Account(); + account2.setName("account-2"); + account2.setApiKey("key-2"); + + Account account3 = new Account(); + account3.setName("account-3"); + account3.setApiKey("key-3"); + + given(accountConfig.getAccounts()).willReturn(List.of(account1, account2, account3)); + + geminiClientManager = new GeminiClientManager(accountConfig, rateLimiterService); } @Test void Round_Robin_방식으로_계정을_순환하며_로드밸런싱한다() { - // given: 3개의 계정이 설정됨 + // given + given(rateLimiterService.acquirePermit(anyString())).willReturn(true); - // when: 30번 호출 (RateLimiter 제한 전) + // when: 30번 호출 List usedAccounts = new ArrayList<>(); for (int i = 0; i < 30; i++) { - Client client = geminiClientManager.getAvailableClient(); - String accountName = geminiClientManager.getCurrentAccountName(); - usedAccounts.add(accountName); - assertThat(client).isNotNull(); + GeminiClientContext context = geminiClientManager.getAvailableClient(); + usedAccounts.add(context.accountName()); + assertThat(context.client()).isNotNull(); } - // then: Round-Robin 패턴 확인 (1→2→3→1→2→3... 순서) - assertThat(usedAccounts.get(0)).isEqualTo("test-account-1"); - assertThat(usedAccounts.get(1)).isEqualTo("test-account-2"); - assertThat(usedAccounts.get(2)).isEqualTo("test-account-3"); - assertThat(usedAccounts.get(3)).isEqualTo("test-account-1"); - assertThat(usedAccounts.get(4)).isEqualTo("test-account-2"); - assertThat(usedAccounts.get(5)).isEqualTo("test-account-3"); + // then: Round-Robin 패턴 확인 + assertThat(usedAccounts.get(0)).isEqualTo("account-1"); + assertThat(usedAccounts.get(1)).isEqualTo("account-2"); + assertThat(usedAccounts.get(2)).isEqualTo("account-3"); + assertThat(usedAccounts.get(3)).isEqualTo("account-1"); // 3개 계정이 모두 골고루 사용됨 long distinctAccounts = usedAccounts.stream().distinct().count(); assertThat(distinctAccounts).isEqualTo(3); - // 각 계정이 10번씩 사용됨 (30번 / 3개 = 10번) - long account1Count = usedAccounts.stream().filter(name -> name.equals("test-account-1")).count(); - long account2Count = usedAccounts.stream().filter(name -> name.equals("test-account-2")).count(); - long account3Count = usedAccounts.stream().filter(name -> name.equals("test-account-3")).count(); + // 각 계정이 10번씩 사용됨 + long account1Count = usedAccounts.stream().filter(name -> name.equals("account-1")).count(); + long account2Count = usedAccounts.stream().filter(name -> name.equals("account-2")).count(); + long account3Count = usedAccounts.stream().filter(name -> name.equals("account-3")).count(); assertThat(account1Count).isEqualTo(10); assertThat(account2Count).isEqualTo(10); @@ -81,77 +85,30 @@ void setUp() { } @Test - void 일일_제한_도달한_계정은_건너뛰고_다음_계정을_선택한다() { - // given: account-1의 일일 호출 횟수를 최대치로 설정 - String account1Name = "test-account-1"; - String dailyKey = "gemini:daily-calls:" + LocalDate.now() + ":" + account1Name; - int maxCallsPerDay = geminiAccountConfig.getMaxCallsPerDay(); - - redisTemplate.opsForValue().set(dailyKey, String.valueOf(maxCallsPerDay)); - - // when: 클라이언트 요청 - Client client = geminiClientManager.getAvailableClient(); - String accountName = geminiClientManager.getCurrentAccountName(); - - // then: account-1이 아닌 다른 계정 반환 - assertThat(client).isNotNull(); - assertThat(accountName).isNotEqualTo(account1Name); - assertThat(accountName).isEqualTo("test-account-2"); - } - - @Test - void 모든_계정의_일일_제한_도달_시_예외가_발생한다() { - // given: 모든 계정의 일일 호출 횟수를 최대치로 설정 - int maxCallsPerDay = geminiAccountConfig.getMaxCallsPerDay(); - LocalDate today = LocalDate.now(); - - for (int i = 1; i <= 3; i++) { - String accountName = "test-account-" + i; - String dailyKey = "gemini:daily-calls:" + today + ":" + accountName; - redisTemplate.opsForValue().set(dailyKey, String.valueOf(maxCallsPerDay)); - } - - // when & then: 예외 발생 - assertThatThrownBy(() -> geminiClientManager.getAvailableClient()) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.GEMINI_RATE_LIMIT_EXCEEDED); - } - - @Test - void API_호출_성공_후_Redis_카운트가_증가한다() { + void 제한_도달한_계정은_건너뛰고_다음_계정을_선택한다() { // given - String accountName = "test-account-1"; - String dailyKey = "gemini:daily-calls:" + LocalDate.now() + ":" + accountName; - - // when: incrementDailyCount 호출 - geminiClientManager.incrementDailyCount(accountName); + // account-1은 제한 도달 (acquirePermit 실패) + given(rateLimiterService.acquirePermit("account-1")).willReturn(false); + // account-2는 정상 + given(rateLimiterService.acquirePermit("account-2")).willReturn(true); - // then: Redis 카운트가 1로 증가 - String count = redisTemplate.opsForValue().get(dailyKey); - assertThat(count).isEqualTo("1"); + // when + GeminiClientContext context = geminiClientManager.getAvailableClient(); - // when: 한 번 더 호출 - geminiClientManager.incrementDailyCount(accountName); - - // then: Redis 카운트가 2로 증가 - String count2 = redisTemplate.opsForValue().get(dailyKey); - assertThat(count2).isEqualTo("2"); + // then + assertThat(context.client()).isNotNull(); + assertThat(context.accountName()).isEqualTo("account-2"); } @Test - void API_호출_성공_후_Redis_TTL이_설정된다() { + void 모든_계정의_제한_도달_시_예외가_발생한다() { // given - String accountName = "test-account-1"; - String dailyKey = "gemini:daily-calls:" + LocalDate.now() + ":" + accountName; - - // when: incrementDailyCount 호출 - geminiClientManager.incrementDailyCount(accountName); + // 모든 계정 제한 도달 + given(rateLimiterService.acquirePermit(anyString())).willReturn(false); - // then: TTL이 설정됨 (자정까지) - Long ttl = redisTemplate.getExpire(dailyKey); - assertThat(ttl).isNotNull(); - assertThat(ttl).isGreaterThan(0); - assertThat(ttl).isLessThanOrEqualTo(86400L); // 24시간 이하 + // when & then + assertThatThrownBy(() -> geminiClientManager.getAvailableClient()) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.GEMINI_RATE_LIMIT_EXCEEDED); } - } diff --git a/src/test/java/book/book/quiz/external/GeminiRetryHandlerTest.java b/src/test/java/book/book/quiz/external/GeminiRetryHandlerTest.java index 20b0afd7..62ffa4aa 100644 --- a/src/test/java/book/book/quiz/external/GeminiRetryHandlerTest.java +++ b/src/test/java/book/book/quiz/external/GeminiRetryHandlerTest.java @@ -15,11 +15,14 @@ import book.book.common.CustomException; import book.book.common.ErrorCode; import book.book.quiz.exception.RateLimitException; +import book.book.quiz.external.gemini.service.GeminiRetryHandler; +import book.book.quiz.external.gemini.apikey.GeminiClientContext; +import book.book.quiz.external.gemini.apikey.GeminiClientManager; import book.book.quiz.service.QuizAlertService; import com.google.genai.errors.ClientException; import java.util.List; import java.util.concurrent.CompletableFuture; -import java.util.function.Supplier; + import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -142,6 +145,8 @@ class Rate_Limit_에러_감지_테스트 { // ===== 동기 재시도 로직 테스트 ===== + // ===== 동기 재시도 로직 테스트 ===== + @Nested class 동기_재시도_로직_테스트 { @@ -149,7 +154,11 @@ class 동기_재시도_로직_테스트 { void 정상_호출시_바로_성공한다() { // given when(clientManager.getAccountCount()).thenReturn(3); - Supplier apiCall = () -> "성공"; + GeminiClientContext context = mock(GeminiClientContext.class); + when(clientManager.getAvailableClient()).thenReturn(context); + when(context.accountName()).thenReturn("test-account"); + + java.util.function.Function apiCall = (ctx) -> "성공"; // when String result = retryHandler.executeWithRetry(apiCall, "테스트북"); @@ -163,13 +172,16 @@ class 동기_재시도_로직_테스트 { void 첫번째_시도에서_429_에러_발생시_두번째_시도에서_성공한다() { // given when(clientManager.getAccountCount()).thenReturn(3); + GeminiClientContext context = mock(GeminiClientContext.class); + when(clientManager.getAvailableClient()).thenReturn(context); + when(context.accountName()).thenReturn("test-account"); - // Mock Supplier: 1차 실패, 2차 성공 - Supplier apiCall = new Supplier<>() { + // Mock Function: 1차 실패, 2차 성공 + java.util.function.Function apiCall = new java.util.function.Function<>() { private int callCount = 0; @Override - public String get() { + public String apply(GeminiClientContext ctx) { callCount++; if (callCount == 1) { throw new RateLimitException(); @@ -190,11 +202,15 @@ public String get() { void 모든_계정에서_429_에러_발생시_최종_실패한다() { // given: 계정 3개, 모두 429 에러 when(clientManager.getAccountCount()).thenReturn(3); - Supplier apiCall = () -> { + GeminiClientContext context = mock(GeminiClientContext.class); + when(clientManager.getAvailableClient()).thenReturn(context); + when(context.accountName()).thenReturn("test-account"); + + java.util.function.Function apiCall = (ctx) -> { throw new RateLimitException(); }; - doNothing().when(quizAlertService).notifyAiApiFailure(anyLong(), any()); + doNothing().when(quizAlertService).notifyAiApiFailure(anyLong(), any(), any()); // when & then: 최종 실패 assertThatThrownBy(() -> retryHandler.executeWithRetry(apiCall, "테스트북")) @@ -202,19 +218,23 @@ public String get() { .hasFieldOrPropertyWithValue("errorCode", ErrorCode.QUIZ_GENERATION_FAILED); // 최종 실패 알림 전송 검증 - verify(quizAlertService, times(1)).notifyAiApiFailure(eq(0L), any(RateLimitException.class)); + verify(quizAlertService, times(1)).notifyAiApiFailure(eq(0L), any(RateLimitException.class), any()); } @Test void 일반_에러_발생시_재시도하지_않고_바로_실패한다() { // given when(clientManager.getAccountCount()).thenReturn(3); + GeminiClientContext context = mock(GeminiClientContext.class); + when(clientManager.getAvailableClient()).thenReturn(context); + when(context.accountName()).thenReturn("test-account"); + RuntimeException generalError = new RuntimeException("500 Internal Server Error"); - Supplier apiCall = () -> { + java.util.function.Function apiCall = (ctx) -> { throw generalError; }; - doNothing().when(quizAlertService).notifyAiApiFailure(anyLong(), any()); + doNothing().when(quizAlertService).notifyAiApiFailure(anyLong(), any(), any()); // when & then: 재시도 없이 바로 실패 assertThatThrownBy(() -> retryHandler.executeWithRetry(apiCall, "테스트북")) @@ -222,20 +242,23 @@ public String get() { .hasFieldOrPropertyWithValue("errorCode", ErrorCode.QUIZ_GENERATION_FAILED); // 1번만 호출되고 알림 전송 - verify(quizAlertService, times(1)).notifyAiApiFailure(eq(0L), any(RuntimeException.class)); + verify(quizAlertService, times(1)).notifyAiApiFailure(eq(0L), any(RuntimeException.class), any()); } @Test void 계정이_5개면_최대_5번까지_재시도한다() { // given when(clientManager.getAccountCount()).thenReturn(5); + GeminiClientContext context = mock(GeminiClientContext.class); + when(clientManager.getAvailableClient()).thenReturn(context); + when(context.accountName()).thenReturn("test-account"); - // Mock Supplier: 4번 실패, 5번째 성공 - Supplier apiCall = new Supplier<>() { + // Mock Function: 4번 실패, 5번째 성공 + java.util.function.Function apiCall = new java.util.function.Function<>() { private int callCount = 0; @Override - public String get() { + public String apply(GeminiClientContext ctx) { callCount++; if (callCount < 5) { throw new RateLimitException(); @@ -261,7 +284,12 @@ class 비동기_재시도_로직_테스트 { void 비동기_정상_호출시_바로_성공한다() { // given when(clientManager.getAccountCount()).thenReturn(3); - Supplier> apiCall = () -> CompletableFuture.completedFuture("비동기 성공"); + GeminiClientContext context = mock(GeminiClientContext.class); + when(clientManager.getAvailableClient()).thenReturn(context); + when(context.accountName()).thenReturn("test-account"); + + java.util.function.Function> apiCall = ( + ctx) -> CompletableFuture.completedFuture("비동기 성공"); // when CompletableFuture resultFuture = retryHandler.executeWithRetryAsync( @@ -277,13 +305,16 @@ class 비동기_재시도_로직_테스트 { void 비동기에서_429_에러_발생시_재시도한다() { // given when(clientManager.getAccountCount()).thenReturn(3); + GeminiClientContext context = mock(GeminiClientContext.class); + when(clientManager.getAvailableClient()).thenReturn(context); + when(context.accountName()).thenReturn("test-account"); - // Mock Supplier: 1차 실패, 2차 성공 - Supplier> apiCall = new Supplier<>() { + // Mock Function: 1차 실패, 2차 성공 + java.util.function.Function> apiCall = new java.util.function.Function<>() { private int callCount = 0; @Override - public CompletableFuture get() { + public CompletableFuture apply(GeminiClientContext ctx) { callCount++; if (callCount == 1) { return CompletableFuture.failedFuture(new RateLimitException()); @@ -305,8 +336,18 @@ public CompletableFuture get() { void 비동기에서_모든_계정_실패시_최종_실패한다() { // given when(clientManager.getAccountCount()).thenReturn(2); - Supplier> apiCall = () -> CompletableFuture - .failedFuture(new RateLimitException()); + // Note: In async retry logic, we check attempt >= maxAttempts at the start. + // If maxAttempts is 2, attempt 0 (fail) -> attempt 1 (fail) -> attempt 2 + // (stop). + // But executeWithRetryAsync is recursive. + // We need to mock getAvailableClient for each attempt. + GeminiClientContext context = mock(GeminiClientContext.class); + when(clientManager.getAvailableClient()).thenReturn(context); + when(context.accountName()).thenReturn("test-account"); + + java.util.function.Function> apiCall = ( + ctx) -> CompletableFuture + .failedFuture(new RateLimitException()); // when CompletableFuture resultFuture = retryHandler.executeWithRetryAsync( @@ -321,10 +362,15 @@ public CompletableFuture get() { void 비동기에서_일반_에러_발생시_재시도하지_않는다() { // given when(clientManager.getAccountCount()).thenReturn(3); + GeminiClientContext context = mock(GeminiClientContext.class); + when(clientManager.getAvailableClient()).thenReturn(context); + when(context.accountName()).thenReturn("test-account"); + RuntimeException generalError = new RuntimeException("500 Internal Server Error"); - Supplier> apiCall = () -> CompletableFuture.failedFuture(generalError); + java.util.function.Function> apiCall = ( + ctx) -> CompletableFuture.failedFuture(generalError); - doNothing().when(quizAlertService).notifyAiApiFailure(anyLong(), any()); + doNothing().when(quizAlertService).notifyAiApiFailure(anyLong(), any(), any()); // when CompletableFuture resultFuture = retryHandler.executeWithRetryAsync( @@ -334,7 +380,7 @@ public CompletableFuture get() { assertThatThrownBy(resultFuture::join) .hasCauseInstanceOf(CustomException.class); - verify(quizAlertService, times(1)).notifyAiApiFailure(eq(0L), any(RuntimeException.class)); + verify(quizAlertService, times(1)).notifyAiApiFailure(eq(0L), any(RuntimeException.class), any()); } } diff --git a/src/test/java/book/book/quiz/external/gemini/service/GeminiRateLimiterServiceTest.java b/src/test/java/book/book/quiz/external/gemini/service/GeminiRateLimiterServiceTest.java new file mode 100644 index 00000000..08961fb8 --- /dev/null +++ b/src/test/java/book/book/quiz/external/gemini/service/GeminiRateLimiterServiceTest.java @@ -0,0 +1,106 @@ +package book.book.quiz.external.gemini.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import book.book.config.IntegrationTest; +import book.book.quiz.config.GeminiAccountConfig; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +@IntegrationTest +class GeminiRateLimiterServiceTest { + + @Autowired + private GeminiRateLimiterService rateLimiterService; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private GeminiAccountConfig accountConfig; + + private static final String REDIS_KEY_DAILY = "gemini:daily-calls:"; + private static final String REDIS_KEY_SLIDING = "gemini:sliding-calls:"; + + @BeforeEach + void setUp() { + String dailyKey = REDIS_KEY_DAILY + LocalDate.now() + ":test-account"; + String windowKey = REDIS_KEY_SLIDING + "test-account"; + redisTemplate.delete(dailyKey); + redisTemplate.delete(windowKey); + } + + @Test + void acquirePermit_성공_시_일일_카운트와_윈도우_카운트가_증가한다() { + // given + String accountName = "test-account"; + + // when + boolean acquired = rateLimiterService.acquirePermit(accountName); + + assertThat(acquired).isTrue(); + assertThat(rateLimiterService.getDailyUsage(accountName)).isEqualTo(1); + assertThat(rateLimiterService.getCurrentCount(accountName)).isEqualTo(1); + } + + @Test + void 일일_제한_초과_시_Permit_획득_실패() { + // given + String accountName = "test-account"; + int maxDaily = accountConfig.getMaxCallsPerDay(); + + // maxDaily만큼 미리 호출 (Redis 직접 조작하여 세팅) + String dailyKey = REDIS_KEY_DAILY + LocalDate.now() + ":" + accountName; + redisTemplate.opsForValue().set(dailyKey, maxDaily); // Pass Integer, not String + + // when + boolean acquired = rateLimiterService.acquirePermit(accountName); + + // then + assertThat(acquired).isFalse(); + // 카운트는 증가하지 않아야 함 + Object currentDaily = redisTemplate.opsForValue().get(dailyKey); + assertThat(Integer.parseInt(String.valueOf(currentDaily))).isEqualTo(maxDaily); + } + + @Test + void RPM_제한_초과_시_Permit_획득_실패() { + // given + String accountName = "test-account"; + int maxRpm = accountConfig.getRequestsPerMinute(); + + // RPM 제한만큼 미리 호출 (Redis ZSET 직접 조작) + String windowKey = REDIS_KEY_SLIDING + accountName; + long now = System.currentTimeMillis(); + for (int i = 0; i < maxRpm; i++) { + redisTemplate.opsForZSet().add(windowKey, now + i, now + i); // Pass Long member, not String + } + + // when + boolean acquired = rateLimiterService.acquirePermit(accountName); + + // then + assertThat(acquired).isFalse(); + // 일일 카운트도 증가하지 않아야 함 (Lua 스크립트에서 먼저 체크하므로) + // 하지만 Lua 스크립트 순서상 Daily Check -> RPM Check -> INCR Daily -> ZADD Window + // RPM 체크에서 걸리면 Daily INCR는 실행되지 않음. + assertThat(rateLimiterService.getDailyUsage(accountName)).isEqualTo(0); + } + + @Test + void rollbackDailyCall_호출_시_일일_카운트가_감소한다() { + // given + String accountName = "test-account"; + rateLimiterService.acquirePermit(accountName); + assertThat(rateLimiterService.getDailyUsage(accountName)).isEqualTo(1); + + // when + rateLimiterService.rollbackDailyCall(accountName); + + // then + assertThat(rateLimiterService.getDailyUsage(accountName)).isEqualTo(0); + } +} diff --git a/src/test/java/book/book/quiz/external/gemini/service/GeminiSdkClientTest.java b/src/test/java/book/book/quiz/external/gemini/service/GeminiSdkClientTest.java new file mode 100644 index 00000000..cef2041d --- /dev/null +++ b/src/test/java/book/book/quiz/external/gemini/service/GeminiSdkClientTest.java @@ -0,0 +1,126 @@ +package book.book.quiz.external.gemini.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import book.book.quiz.dto.external.GeminiRequest; +import book.book.quiz.external.gemini.apikey.GeminiClientContext; +import book.book.quiz.service.GeminiTokenTracker; +import com.google.genai.Client; +import com.google.genai.types.GenerateContentResponse; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GeminiSdkClientTest { + + @Mock + private GeminiRateLimiterService rateLimiterService; + + @Mock + private GeminiTokenTracker tokenTracker; + + private GeminiSdkClient geminiSdkClient; + + @Mock + private Client client; + + private GeminiClientContext context; + + @BeforeEach + void setUp() { + // Spy on the SUT + geminiSdkClient = org.mockito.Mockito.spy(new GeminiSdkClient(rateLimiterService, tokenTracker)); + context = new GeminiClientContext(client, "test-account"); + } + + @Test + void 동기_호출_실패_시_일일_제한을_롤백한다() { + // given + GeminiRequest request = GeminiRequest.builder() + .prompt("test prompt") + .temperature(0.5f) + .contextForLogging("test-context") + .systemInstruction(mock(com.google.genai.types.Content.class)) + .schema(mock(com.google.genai.types.Schema.class)) + .build(); + + // Mock the internal method + org.mockito.Mockito.doThrow(new RuntimeException("API Error")) + .when(geminiSdkClient).generateContentInternal(any(), anyString(), any()); + + // when & then + assertThatThrownBy(() -> geminiSdkClient.generateContent(context, request)) + .isInstanceOf(RuntimeException.class) + .hasMessage("API Error"); + + // verify rollback + verify(rateLimiterService).rollbackDailyCall("test-account"); + } + + @Test + void 비동기_호출_실패_시_일일_제한을_롤백한다() { + // given + GeminiRequest request = GeminiRequest.builder() + .prompt("test prompt") + .temperature(0.5f) + .contextForLogging("test-context") + .systemInstruction(mock(com.google.genai.types.Content.class)) + .schema(mock(com.google.genai.types.Schema.class)) + .build(); + + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(new RuntimeException("Async API Error")); + + // Mock the internal method + org.mockito.Mockito.doReturn(failedFuture) + .when(geminiSdkClient).generateContentAsyncInternal(any(), anyString(), any()); + + // when + CompletableFuture resultFuture = geminiSdkClient.generateContentAsync(context, + request); + + // then + assertThatThrownBy(resultFuture::get) + .isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(RuntimeException.class) + .hasMessageContaining("Async API Error"); + + // verify rollback + verify(rateLimiterService).rollbackDailyCall("test-account"); + } + + @Test + void 성공_시에는_롤백하지_않고_토큰을_기록한다() { + // given + GeminiRequest request = GeminiRequest.builder() + .prompt("test prompt") + .temperature(0.5f) + .contextForLogging("test-context") + .systemInstruction(mock(com.google.genai.types.Content.class)) + .schema(mock(com.google.genai.types.Schema.class)) + .build(); + + GenerateContentResponse response = mock(GenerateContentResponse.class); + + // Mock the internal method + org.mockito.Mockito.doReturn(response) + .when(geminiSdkClient).generateContentInternal(any(), anyString(), any()); + + // when + geminiSdkClient.generateContent(context, request); + + // then + verify(rateLimiterService, org.mockito.Mockito.never()).rollbackDailyCall(anyString()); + verify(tokenTracker).recordTokenUsage(eq("test-account"), eq(response)); + } +} diff --git a/src/test/java/book/book/quiz/service/QuizGenerationAsyncServiceTest.java b/src/test/java/book/book/quiz/service/QuizGenerationAsyncServiceTest.java index 579df8c9..105158f3 100644 --- a/src/test/java/book/book/quiz/service/QuizGenerationAsyncServiceTest.java +++ b/src/test/java/book/book/quiz/service/QuizGenerationAsyncServiceTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -14,8 +13,10 @@ import book.book.challenge.fixture.ChapterFixture; 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.GeminiSdkClient; +import book.book.quiz.external.gemini.GeminiApiService; +import book.book.quiz.external.gemini.prompt.GeminiRequestFactory; import book.book.quiz.fixture.GeminiQuizResponsesFixture; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -32,7 +33,7 @@ class QuizGenerationAsyncServiceTest { @Mock - private GeminiSdkClient geminiSdkClient; + private GeminiApiService geminiApiService; @Mock private QuizSaveService quizSaveService; @@ -43,6 +44,9 @@ class QuizGenerationAsyncServiceTest { @Mock private QuizAlertService quizAlertService; + @Mock + private GeminiRequestFactory geminiRequestFactory; + @InjectMocks private QuizGenerationAsyncService quizGenerationAsyncService; @@ -63,11 +67,13 @@ void setUp() { void 퀴즈_생성_성공_시_QuizCreatedEvent를_발행한다() { // given GeminiQuizResponses batchResponse = GeminiQuizResponsesFixture.createWithChapters(chapters); + GeminiRequest request = GeminiRequest.builder() + .contextForLogging(book.getTitle()) + .build(); - given(geminiSdkClient.generateBatchQuizzesAsync( - anyString(), - anyString(), - anyList())).willReturn(CompletableFuture.completedFuture(batchResponse)); + given(geminiRequestFactory.createBatchQuizRequest(any(Book.class), anyList())).willReturn(request); + given(geminiApiService.generateContentAsync(any(GeminiRequest.class))) + .willReturn(CompletableFuture.completedFuture(batchResponse)); // when CompletableFuture future = quizGenerationAsyncService.generateQuizzes(book, chapters, memberId); @@ -86,11 +92,13 @@ void setUp() { void 퀴즈_생성_성공_시_퀴즈를_저장하고_상태를_업데이트한다() { // given GeminiQuizResponses batchResponse = GeminiQuizResponsesFixture.createWithChapters(chapters); + GeminiRequest request = GeminiRequest.builder() + .contextForLogging(book.getTitle()) + .build(); - given(geminiSdkClient.generateBatchQuizzesAsync( - anyString(), - anyString(), - anyList())).willReturn(CompletableFuture.completedFuture(batchResponse)); + given(geminiRequestFactory.createBatchQuizRequest(any(Book.class), anyList())).willReturn(request); + given(geminiApiService.generateContentAsync(any(GeminiRequest.class))) + .willReturn(CompletableFuture.completedFuture(batchResponse)); // when CompletableFuture future = quizGenerationAsyncService.generateQuizzes(book, chapters, memberId); @@ -106,11 +114,13 @@ void setUp() { void 퀴즈_저장_후_이벤트가_발행된다() { // given GeminiQuizResponses batchResponse = GeminiQuizResponsesFixture.createWithChapters(chapters); + GeminiRequest request = GeminiRequest.builder() + .contextForLogging(book.getTitle()) + .build(); - given(geminiSdkClient.generateBatchQuizzesAsync( - anyString(), - anyString(), - anyList())).willReturn(CompletableFuture.completedFuture(batchResponse)); + given(geminiRequestFactory.createBatchQuizRequest(any(Book.class), anyList())).willReturn(request); + given(geminiApiService.generateContentAsync(any(GeminiRequest.class))) + .willReturn(CompletableFuture.completedFuture(batchResponse)); // when CompletableFuture future = quizGenerationAsyncService.generateQuizzes(book, chapters, memberId); @@ -127,11 +137,12 @@ void setUp() { // given CompletableFuture failedFuture = new CompletableFuture<>(); failedFuture.completeExceptionally(new RuntimeException("Gemini API 호출 실패")); + GeminiRequest request = GeminiRequest.builder() + .contextForLogging(book.getTitle()) + .build(); - given(geminiSdkClient.generateBatchQuizzesAsync( - anyString(), - anyString(), - anyList())).willReturn(failedFuture); + given(geminiRequestFactory.createBatchQuizRequest(any(Book.class), anyList())).willReturn(request); + given(geminiApiService.generateContentAsync(any(GeminiRequest.class))).willReturn(failedFuture); // when CompletableFuture future = quizGenerationAsyncService.generateQuizzes(book, chapters, memberId); @@ -148,11 +159,12 @@ void setUp() { // given CompletableFuture failedFuture = new CompletableFuture<>(); failedFuture.completeExceptionally(new RuntimeException("Gemini API 호출 실패")); + GeminiRequest request = GeminiRequest.builder() + .contextForLogging(book.getTitle()) + .build(); - given(geminiSdkClient.generateBatchQuizzesAsync( - anyString(), - anyString(), - anyList())).willReturn(failedFuture); + given(geminiRequestFactory.createBatchQuizRequest(any(Book.class), anyList())).willReturn(request); + given(geminiApiService.generateContentAsync(any(GeminiRequest.class))).willReturn(failedFuture); // when CompletableFuture future = quizGenerationAsyncService.generateQuizzes(book, chapters, memberId); @@ -166,11 +178,13 @@ void setUp() { void 퀴즈_저장_중_예외_발생_시_updateStatusToFailed를_호출한다() { // given GeminiQuizResponses batchResponse = GeminiQuizResponsesFixture.createWithChapters(chapters); + GeminiRequest request = GeminiRequest.builder() + .contextForLogging(book.getTitle()) + .build(); - given(geminiSdkClient.generateBatchQuizzesAsync( - anyString(), - anyString(), - anyList())).willReturn(CompletableFuture.completedFuture(batchResponse)); + given(geminiRequestFactory.createBatchQuizRequest(any(Book.class), anyList())).willReturn(request); + given(geminiApiService.generateContentAsync(any(GeminiRequest.class))) + .willReturn(CompletableFuture.completedFuture(batchResponse)); // quizSaveService에서 예외 발생 given(quizSaveService.saveQuizzesBatchAndUpdateStatus(any(), anyList(), any()))