Feat/124 OpenAi 호출 실패 시 Claude Api 호출하는 Fallback구조 도입#142
Conversation
WalkthroughAI 챗 클라이언트 계층이 도입되어 OpenAI와 Anthropic Claude 모델을 모두 지원하며, OpenAI 실패 시 Claude로 자동 폴백하는 메커니즘이 추가되었습니다. 관련 서비스, 설정, 테스트 코드가 이에 맞게 수정 및 확장되었으며, 퀴즈 카테고리 관련 리포지토리와 테스트 로직도 개선되었습니다. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant AiService
participant FallbackAiChatClient
participant OpenAiChatClient
participant ClaudeChatClient
User->>AiService: 피드백 요청
AiService->>FallbackAiChatClient: call(systemPrompt, userPrompt)
FallbackAiChatClient->>OpenAiChatClient: call(systemPrompt, userPrompt)
OpenAiChatClient-->>FallbackAiChatClient: Exception 발생
FallbackAiChatClient->>ClaudeChatClient: call(systemPrompt, userPrompt)
ClaudeChatClient-->>FallbackAiChatClient: Claude 응답
FallbackAiChatClient-->>AiService: Claude 응답 반환
AiService-->>User: 피드백 결과 반환
Suggested reviewers
Poem
✨ Finishing Touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments. CodeRabbit Commands (Invoked using PR comments)
Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (11)
cs25-service/src/test/java/com/example/cs25service/ai/AiQuestionGeneratorServiceTest.java (1)
52-61: 테스트 설정 개선이 우수합니다.동적 카테고리 존재 확인과 조건부 삽입으로 테스트의 안정성이 크게 향상되었습니다:
- 중복 엔티티 삽입 방지
- EntityManager flush/clear로 적절한 영속성 상태 관리
- 새로 추가된
existsByCategoryType메서드의 효과적인 활용테스트 설정을 더욱 개선하려면 카테고리 목록을 설정 파일이나 상수로 분리하는 것을 고려해보세요:
private static final List<String> REQUIRED_CATEGORIES = List.of( "SoftwareDevelopment", "SoftwareDesign", "Programming", "Database", "InformationSystemManagement" );cs25-service/src/test/java/com/example/cs25service/ai/FallbackAiChatClientTest.java (1)
14-14: 테스트 메서드 이름을 더 구체적으로 작성하세요.메서드 이름이 시나리오를 충분히 설명하지 못합니다. 더 명확하고 구체적인 이름을 사용해주세요.
-void openAiFail_thenFallbackToClaude() { +void shouldFallbackToClaudeWhenOpenAiThrowsException() {cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiService.java (1)
13-13: 사용하지 않는 import를 제거하세요.
ChatClientimport가 더 이상 사용되지 않으므로 제거해주세요.-import org.springframework.ai.chat.client.ChatClient;cs25-service/src/main/java/com/example/cs25service/domain/ai/client/FallbackAiChatClient.java (1)
28-30: Circuit Breaker 패턴 도입을 고려하세요.현재 구현은 매번 OpenAI를 먼저 시도하므로, OpenAI가 지속적으로 실패하는 상황에서 불필요한 지연이 발생할 수 있습니다. Circuit Breaker 패턴을 도입하여 일정 시간 동안은 Claude를 직접 사용하는 것을 고려해보세요.
Spring Cloud Circuit Breaker나 Resilience4j를 활용할 수 있습니다:
+@CircuitBreaker(name = "openai", fallbackMethod = "fallbackToClaude") public String call(String systemPrompt, String userPrompt) { - try { return openAiClient.call(systemPrompt, userPrompt); - } catch (Exception e) { - log.warn("OpenAI 호출 실패. Claude로 폴백합니다.", e); - return claudeClient.call(systemPrompt, userPrompt); - } } +public String fallbackToclaude(String systemPrompt, String userPrompt, Exception ex) { + log.warn("OpenAI 호출 실패. Claude로 폴백합니다.", ex); + return claudeClient.call(systemPrompt, userPrompt); +}cs25-service/src/test/java/com/example/cs25service/ai/FallbackAiChatClientIntegrationTest.java (2)
98-98: 콘솔 출력을 제거하고 적절한 로깅 또는 어서션으로 대체하세요.프로덕션 코드에서
System.out.println은 적절하지 않습니다. 테스트에서도 로거를 사용하거나 필요시 제거해주세요.-System.out.println("📢 Claude 기반 피드백: " + updated.getAiFeedback()); +log.debug("Claude 기반 피드백: {}", updated.getAiFeedback());
46-89: 테스트 데이터 생성 로직을 헬퍼 메서드로 추출하세요.테스트 설정 코드가 길어서 테스트의 핵심 로직이 가려집니다. 테스트 데이터 생성 로직을 별도 메서드로 추출하여 가독성을 향상시켜주세요.
+private UserQuizAnswer createTestUserQuizAnswer() { + QuizCategory category = QuizCategory.builder() + .categoryType("네트워크") + .parent(null) + .build(); + em.persist(category); + + // ... 나머지 엔티티 생성 로직 + + return answer; +} @Test @DisplayName("OpenAI 호출 실패 시 Claude로 폴백하여 피드백 생성한다") void openAiFail_thenUseClaudeFeedback() { - // given - 기본 퀴즈, 사용자, 정답 생성 - QuizCategory category = QuizCategory.builder()... + // given + UserQuizAnswer answer = createTestUserQuizAnswer();cs25-service/src/main/java/com/example/cs25service/domain/ai/client/ClaudeChatClient.java (1)
14-20: 입력 파라미터 검증을 추가하는 것을 고려하세요.현재 null 체크나 빈 문자열 검증이 없습니다. 로버스트한 구현을 위해 입력 검증을 추가하는 것을 고려해보세요.
@Override public String call(String systemPrompt, String userPrompt) { + if (systemPrompt == null || systemPrompt.trim().isEmpty()) { + throw new IllegalArgumentException("System prompt는 필수입니다."); + } + if (userPrompt == null || userPrompt.trim().isEmpty()) { + throw new IllegalArgumentException("User prompt는 필수입니다."); + } + return anthropicChatClient.prompt() .system(systemPrompt) .user(userPrompt) .call() .content(); }cs25-service/src/main/java/com/example/cs25service/domain/ai/config/AiConfig.java (3)
36-45: Claude API 키 설정 일관성 확인 필요Anthropic 모델 설정에서
claudeKey파라미터를 사용하고 있지만, 프로퍼티 키는spring.ai.anthropic.api-key입니다. 일관성을 위해 변수명을anthropicKey로 변경하는 것을 고려하세요.다음과 같이 수정하여 일관성을 높이세요:
@Bean - public AnthropicChatModel anthropicChatModel(@Value("${spring.ai.anthropic.api-key}") String claudeKey) { + public AnthropicChatModel anthropicChatModel(@Value("${spring.ai.anthropic.api-key}") String anthropicKey) { AnthropicApi api = AnthropicApi.builder() - .apiKey(claudeKey) + .apiKey(anthropicKey) .build();
21-23: 메서드명의 네이밍 일관성을 개선하세요.메서드명이
AichatClient로 되어 있는데, Java 네이밍 컨벤션에 따라aiChatClient로 수정하는 것이 좋습니다.-public ChatClient AichatClient(OpenAiChatModel chatModel) { +public ChatClient aiChatClient(OpenAiChatModel chatModel) { return ChatClient.create(chatModel); }
36-45: API 키 검증 로직을 추가하는 것을 고려하세요.Anthropic 모델 설정이 올바르게 구성되었지만, API 키가 비어있거나 유효하지 않은 경우에 대한 검증 로직을 추가하면 더 안전합니다.
@Bean public AnthropicChatModel anthropicChatModel(@Value("${spring.ai.anthropic.api-key}") String claudeKey) { + if (claudeKey == null || claudeKey.trim().isEmpty()) { + throw new IllegalArgumentException("Anthropic API 키가 설정되지 않았습니다."); + } + AnthropicApi api = AnthropicApi.builder() .apiKey(claudeKey) .build(); return AnthropicChatModel.builder() .anthropicApi(api) .build(); }OpenAI 모델 설정에도 동일한 검증을 추가하는 것을 고려해보세요.
cs25-service/src/main/java/com/example/cs25service/domain/ai/client/OpenAiChatClient.java (1)
29-32: raw() 메서드의 접근 제어를 고려하세요.
raw()메서드가 내부ChatClient를 직접 노출합니다. 이는 캡슐화를 위반할 수 있으며, 클라이언트가 직접 내부 구현에 의존하게 만들 수 있습니다.사용 사례를 명확히 하고, 필요하다면 더 구체적인 메서드들을 제공하는 것을 고려해보세요:
@Override -public ChatClient raw() { - return openAiChatClient; -} +public ChatClient getChatClient() { + // 특정 사용 사례에 대한 문서화 필요 + return openAiChatClient; +}
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (12)
cs25-entity/src/main/java/com/example/cs25entity/domain/quiz/repository/QuizCategoryRepository.java(1 hunks)cs25-service/build.gradle(1 hunks)cs25-service/src/main/java/com/example/cs25service/domain/ai/client/AiChatClient.java(1 hunks)cs25-service/src/main/java/com/example/cs25service/domain/ai/client/ClaudeChatClient.java(1 hunks)cs25-service/src/main/java/com/example/cs25service/domain/ai/client/FallbackAiChatClient.java(1 hunks)cs25-service/src/main/java/com/example/cs25service/domain/ai/client/OpenAiChatClient.java(1 hunks)cs25-service/src/main/java/com/example/cs25service/domain/ai/config/AiConfig.java(2 hunks)cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiService.java(2 hunks)cs25-service/src/main/resources/application.properties(1 hunks)cs25-service/src/test/java/com/example/cs25service/ai/AiQuestionGeneratorServiceTest.java(1 hunks)cs25-service/src/test/java/com/example/cs25service/ai/FallbackAiChatClientIntegrationTest.java(1 hunks)cs25-service/src/test/java/com/example/cs25service/ai/FallbackAiChatClientTest.java(1 hunks)
🔇 Additional comments (22)
cs25-service/build.gradle (1)
27-27: 의존성 추가가 적절합니다.Claude AI 모델 지원을 위한 Anthropic 의존성이 올바르게 추가되었으며, 기존 OpenAI 의존성과 버전이 일치하여 일관성을 유지하고 있습니다.
cs25-service/src/main/resources/application.properties (2)
54-54: 설명 주석이 더 명확해졌습니다.AI 설정 섹션을 "OPEN AI"로 명시하여 Claude 설정과 구분이 명확해졌습니다.
59-61: Claude 모델 버전 확인이 필요합니다.Claude 설정이 올바르게 추가되었지만, 지정된 모델
claude-3-opus-20240229이 현재 사용 가능한 최신 버전인지 확인이 필요합니다.Anthropic Claude 3 Opus 모델의 최신 버전과 사용 가능한 모델 목록을 확인해주세요. claude-3-opus-20240229가 여전히 유효한 모델 이름인지 확인이 필요합니다.cs25-entity/src/main/java/com/example/cs25entity/domain/quiz/repository/QuizCategoryRepository.java (1)
48-48: 리포지토리 메서드 추가가 적절합니다.
existsByCategoryType메서드가 Spring Data JPA 명명 규칙에 따라 올바르게 추가되었으며, 테스트 설정에서 중복 카테고리 삽입을 방지하는 용도로 적절히 활용됩니다.cs25-service/src/main/java/com/example/cs25service/domain/ai/client/AiChatClient.java (1)
5-10: AI 클라이언트 인터페이스 설계가 우수합니다.인터페이스가 잘 설계되어 있습니다:
call()메서드는 AI 호출을 위한 간단하고 직관적인 인터페이스를 제공raw()메서드는 필요시 원본 ChatClient에 접근할 수 있는 확장성 제공- 메서드 시그니처가 명확하고 다양한 AI 제공업체 구현체를 지원하기에 적합
cs25-service/src/test/java/com/example/cs25service/ai/AiQuestionGeneratorServiceTest.java (1)
44-50: 카테고리 타입이 영어로 변경되었습니다.카테고리 타입이 한국어에서 영어로 변경되어 일관성이 개선되었습니다. 하지만 이 변경이 기존 데이터와 호환되는지 확인이 필요합니다.
기존 데이터베이스의 QuizCategory 테이블에 한국어 카테고리가 존재하는지, 그리고 이 변경이 기존 데이터와의 호환성에 영향을 주는지 확인하는 스크립트:
#!/bin/bash # 기존 QuizCategory 사용을 찾아 언어 일관성 검증 echo "QuizCategory 관련 코드에서 한국어/영어 카테고리 사용 패턴 검색:" rg -A 3 -B 3 "QuizCategory\(" --type java echo -e "\n카테고리 타입 문자열 패턴 검색:" rg -A 2 -B 2 "(소프트웨어|프로그래밍|데이터베이스|Software|Programming|Database)" --type javacs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiService.java (2)
21-22: 의존성 주입 방식이 개선되었습니다.
@Qualifier를 사용하여 구체적인 구현체를 지정한 것은 좋은 접근입니다. 이를 통해 여러AiChatClient구현체 중에서 원하는 것을 명확히 선택할 수 있습니다.
40-40: FallbackAiChatClient의call메서드 구현부를 제대로 확인하기 위해 특수문자를 이스케이프하거나 고정 문자열 검색 옵션을 사용하는 스크립트를 다시 실행해주세요.#!/bin/bash # FallbackAiChatClient 파일 경로 찾기 files=$(rg --files-with-matches "class FallbackAiChatClient" -g "*.java") if [ -z "$files" ]; then echo "❌ FallbackAiChatClient 클래스를 찾을 수 없습니다." exit 1 fi echo "✅ FallbackAiChatClient 파일:" echo "$files" echo # 각 파일에서 public String call 선언과 그 후 30줄 출력 for file in $files; do echo "---- $file ----" rg -nF "public String call" "$file" -A 30 echo donecs25-service/src/main/java/com/example/cs25service/domain/ai/client/FallbackAiChatClient.java (1)
8-11: @primary 어노테이션 사용이 적절합니다.여러
AiChatClient구현체 간의 빈 충돌을 해결하기 위한@Primary어노테이션 사용이 적절합니다. 이를 통해 기본 구현체로 폴백 클라이언트가 선택됩니다.cs25-service/src/main/java/com/example/cs25service/domain/ai/client/ClaudeChatClient.java (1)
7-26: 구현이 일관성 있고 간결합니다.
AiChatClient인터페이스를 올바르게 구현했으며, Spring의 빌더 패턴을 적절히 활용했습니다. 다른 클라이언트 구현체들과 일관된 패턴을 따르고 있어 좋습니다.cs25-service/src/main/java/com/example/cs25service/domain/ai/client/OpenAiChatClient.java (5)
16-23: 메서드 구현이 올바르게 되어 있습니다OpenAI ChatClient API를 올바르게 사용하여 시스템 프롬프트와 사용자 프롬프트를 처리하고 있습니다. 응답 내용의 공백 제거도 적절합니다.
9-11: 컴포넌트 스캔 범위 확인 필요
@Component어노테이션이 적용된 클래스가 올바른 패키지 구조에 위치하는지 확인하세요. 도메인 레이어에 있는 클래스에@Component를 사용하는 것이 적절한지 검토가 필요합니다.다음 스크립트로 컴포넌트 스캔 설정을 확인하세요:
#!/bin/bash # Description: Spring Boot 메인 클래스의 컴포넌트 스캔 설정 확인 # 메인 클래스와 @ComponentScan 설정 찾기 rg -A 5 "@SpringBootApplication|@ComponentScan" --type java
29-32: raw() 메서드의 목적 명확화 필요
raw()메서드가 내부ChatClient를 직접 노출하는 것은 캡슐화 원칙에 어긋날 수 있습니다. 이 메서드의 사용 목적과 필요성을 재검토하세요.다음 스크립트로
raw()메서드의 사용처를 확인하세요:#!/bin/bash # Description: raw() 메서드 사용처 확인 # raw() 메서드 호출 찾기 rg -A 3 "\.raw\(\)" --type java
1-8: 패키지 구조와 임포트가 적절합니다.도메인 주도 설계 원칙을 따른 패키지 구조가 잘 정의되어 있고, 필요한 의존성들이 적절하게 임포트되어 있습니다.
9-13: 컴포넌트 설정이 올바릅니다.Spring의
@Component와 Lombok의@RequiredArgsConstructor를 적절하게 사용하여 의존성 주입을 구현했습니다.cs25-service/src/main/java/com/example/cs25service/domain/ai/config/AiConfig.java (7)
1-1: 패키지 구조 변경이 적절합니다AI 관련 설정을 도메인별로 분리한 패키지 구조 변경이 좋습니다. 관심사 분리 원칙에 부합합니다.
25-34: OpenAI 모델 빈 설정이 올바릅니다OpenAI API 키를 사용하여 ChatModel을 적절히 구성하고 있습니다. 빌더 패턴 사용도 적절합니다.
17-18: ```shell
#!/bin/bashapplication.properties에서 spring.ai.openai.api-key 설정 확인
grep -R "spring.ai.openai.api-key" -n cs25-service/src/main/resources/application.properties
--- `1-1`: **패키지 재구성이 적절합니다.** 도메인 주도 설계 원칙에 따라 AI 관련 설정을 `domain.ai.config` 패키지로 이동한 것이 좋습니다. --- `3-4`: **Anthropic 지원을 위한 임포트가 추가되었습니다.** Claude 모델 지원을 위한 필요한 임포트들이 적절하게 추가되었습니다. --- `25-34`: **OpenAI 모델 설정이 적절합니다.** 빌더 패턴을 사용하여 OpenAI 모델을 올바르게 구성했습니다. API 키 주입도 적절하게 처리되었습니다. --- `25-45`: 아래 스크립트를 실행하여 `FallbackAiChatClient` 클래스 파일별로 `@Primary` 어노테이션 존재 여부를 정확히 확인해 주세요: ```shell #!/bin/bash # 1) FallbackAiChatClient 클래스가 정의된 모든 파일 목록 조회 files=$(rg -l "class FallbackAiChatClient") if [ -z "$files" ]; then echo "FallbackAiChatClient 클래스 파일을 찾을 수 없습니다." exit 1 fi # 2) 각 파일에서 @Primary 어노테이션 여부 확인 for file in $files; do echo "---- 검사 파일: $file ----" rg -n "@Primary" -C2 "$file" || echo "-> @Primary 어노테이션이 없습니다." done
| @Test | ||
| void openAiFail_thenFallbackToClaude() { | ||
| // given | ||
| OpenAiChatClient openAiMock = mock(OpenAiChatClient.class); | ||
| ClaudeChatClient claudeMock = mock(ClaudeChatClient.class); | ||
|
|
||
| // OpenAI는 실패하도록 설정 | ||
| when(openAiMock.call(anyString(), anyString())) | ||
| .thenThrow(new RuntimeException("OpenAI failure")); | ||
|
|
||
| // Claude는 정상 반환 | ||
| when(claudeMock.call(anyString(), anyString())) | ||
| .thenReturn("Claude 응답입니다."); | ||
|
|
||
| FallbackAiChatClient fallbackClient = new FallbackAiChatClient(openAiMock, claudeMock); | ||
|
|
||
| // when | ||
| String result = fallbackClient.call("시스템 프롬프트", "유저 프롬프트"); | ||
|
|
||
| // then | ||
| assertThat(result).isEqualTo("Claude 응답입니다."); | ||
| verify(openAiMock, times(1)).call(anyString(), anyString()); | ||
| verify(claudeMock, times(1)).call(anyString(), anyString()); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
추가 테스트 시나리오 구현을 권장합니다.
현재 테스트는 OpenAI 실패 시 Claude로 폴백하는 경우만 검증하고 있습니다. 더 포괄적인 테스트 커버리지를 위해 다음 시나리오들을 추가해주세요:
- OpenAI 성공 시 Claude가 호출되지 않는 경우
- 두 클라이언트 모두 실패하는 경우
- Claude 폴백도 실패하는 경우
@Test
void openAiSuccess_thenClaudeNotCalled() {
// given
OpenAiChatClient openAiMock = mock(OpenAiChatClient.class);
ClaudeChatClient claudeMock = mock(ClaudeChatClient.class);
when(openAiMock.call(anyString(), anyString()))
.thenReturn("OpenAI 응답입니다.");
FallbackAiChatClient fallbackClient = new FallbackAiChatClient(openAiMock, claudeMock);
// when
String result = fallbackClient.call("시스템 프롬프트", "유저 프롬프트");
// then
assertThat(result).isEqualTo("OpenAI 응답입니다.");
verify(openAiMock, times(1)).call(anyString(), anyString());
verify(claudeMock, never()).call(anyString(), anyString());
}
@Test
void bothClientsFail_thenExceptionThrown() {
// given
OpenAiChatClient openAiMock = mock(OpenAiChatClient.class);
ClaudeChatClient claudeMock = mock(ClaudeChatClient.class);
when(openAiMock.call(anyString(), anyString()))
.thenThrow(new RuntimeException("OpenAI failure"));
when(claudeMock.call(anyString(), anyString()))
.thenThrow(new RuntimeException("Claude failure"));
FallbackAiChatClient fallbackClient = new FallbackAiChatClient(openAiMock, claudeMock);
// when & then
assertThatThrownBy(() -> fallbackClient.call("시스템 프롬프트", "유저 프롬프트"))
.isInstanceOf(RuntimeException.class)
.hasMessage("Claude failure");
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @Test | |
| void openAiFail_thenFallbackToClaude() { | |
| // given | |
| OpenAiChatClient openAiMock = mock(OpenAiChatClient.class); | |
| ClaudeChatClient claudeMock = mock(ClaudeChatClient.class); | |
| // OpenAI는 실패하도록 설정 | |
| when(openAiMock.call(anyString(), anyString())) | |
| .thenThrow(new RuntimeException("OpenAI failure")); | |
| // Claude는 정상 반환 | |
| when(claudeMock.call(anyString(), anyString())) | |
| .thenReturn("Claude 응답입니다."); | |
| FallbackAiChatClient fallbackClient = new FallbackAiChatClient(openAiMock, claudeMock); | |
| // when | |
| String result = fallbackClient.call("시스템 프롬프트", "유저 프롬프트"); | |
| // then | |
| assertThat(result).isEqualTo("Claude 응답입니다."); | |
| verify(openAiMock, times(1)).call(anyString(), anyString()); | |
| verify(claudeMock, times(1)).call(anyString(), anyString()); | |
| } | |
| @Test | |
| void openAiFail_thenFallbackToClaude() { | |
| // given | |
| OpenAiChatClient openAiMock = mock(OpenAiChatClient.class); | |
| ClaudeChatClient claudeMock = mock(ClaudeChatClient.class); | |
| // OpenAI는 실패하도록 설정 | |
| when(openAiMock.call(anyString(), anyString())) | |
| .thenThrow(new RuntimeException("OpenAI failure")); | |
| // Claude는 정상 반환 | |
| when(claudeMock.call(anyString(), anyString())) | |
| .thenReturn("Claude 응답입니다."); | |
| FallbackAiChatClient fallbackClient = new FallbackAiChatClient(openAiMock, claudeMock); | |
| // when | |
| String result = fallbackClient.call("시스템 프롬프트", "유저 프롬프트"); | |
| // then | |
| assertThat(result).isEqualTo("Claude 응답입니다."); | |
| verify(openAiMock, times(1)).call(anyString(), anyString()); | |
| verify(claudeMock, times(1)).call(anyString(), anyString()); | |
| } | |
| @Test | |
| void openAiSuccess_thenClaudeNotCalled() { | |
| // given | |
| OpenAiChatClient openAiMock = mock(OpenAiChatClient.class); | |
| ClaudeChatClient claudeMock = mock(ClaudeChatClient.class); | |
| when(openAiMock.call(anyString(), anyString())) | |
| .thenReturn("OpenAI 응답입니다."); | |
| FallbackAiChatClient fallbackClient = new FallbackAiChatClient(openAiMock, claudeMock); | |
| // when | |
| String result = fallbackClient.call("시스템 프롬프트", "유저 프롬프트"); | |
| // then | |
| assertThat(result).isEqualTo("OpenAI 응답입니다."); | |
| verify(openAiMock, times(1)).call(anyString(), anyString()); | |
| verify(claudeMock, never()).call(anyString(), anyString()); | |
| } | |
| @Test | |
| void bothClientsFail_thenExceptionThrown() { | |
| // given | |
| OpenAiChatClient openAiMock = mock(OpenAiChatClient.class); | |
| ClaudeChatClient claudeMock = mock(ClaudeChatClient.class); | |
| when(openAiMock.call(anyString(), anyString())) | |
| .thenThrow(new RuntimeException("OpenAI failure")); | |
| when(claudeMock.call(anyString(), anyString())) | |
| .thenThrow(new RuntimeException("Claude failure")); | |
| FallbackAiChatClient fallbackClient = new FallbackAiChatClient(openAiMock, claudeMock); | |
| // when & then | |
| assertThatThrownBy(() -> fallbackClient.call("시스템 프롬프트", "유저 프롬프트")) | |
| .isInstanceOf(RuntimeException.class) | |
| .hasMessage("Claude failure"); | |
| } |
🤖 Prompt for AI Agents
In
cs25-service/src/test/java/com/example/cs25service/ai/FallbackAiChatClientTest.java
around lines 13 to 36, the current test only covers the scenario where OpenAI
fails and the fallback to Claude succeeds. Add additional test methods to cover
these cases: 1) OpenAI succeeds and Claude is not called, 2) both OpenAI and
Claude clients fail, and 3) the fallback to Claude also fails. For each, mock
the clients accordingly, invoke the fallback client, and assert the expected
behavior or exceptions.
| } catch (Exception e) { | ||
| log.warn("OpenAI 호출 실패. Claude로 폴백합니다.", e); | ||
| return claudeClient.call(systemPrompt, userPrompt); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
예외 처리 범위를 구체화하세요.
모든 Exception을 잡아서 폴백하는 것은 너무 광범위할 수 있습니다. 특정 AI 관련 예외만 폴백 대상으로 하는 것을 고려해보세요.
-} catch (Exception e) {
+} catch (RuntimeException | java.io.IOException e) {
log.warn("OpenAI 호출 실패. Claude로 폴백합니다.", e);
return claudeClient.call(systemPrompt, userPrompt);또는 AI 서비스 특화 예외를 정의하여 사용하는 것도 좋은 방법입니다.
🤖 Prompt for AI Agents
In
cs25-service/src/main/java/com/example/cs25service/domain/ai/client/FallbackAiChatClient.java
around lines 21 to 24, the catch block currently catches all Exceptions which is
too broad. Narrow the exception handling to catch only specific AI-related
exceptions that should trigger the fallback to Claude. Consider defining and
using a custom AI service exception class to encapsulate these specific errors
and catch that instead of the generic Exception.
| void openAiFail_thenUseClaudeFeedback() { | ||
| // given - 기본 퀴즈, 사용자, 정답 생성 | ||
| QuizCategory category = QuizCategory.builder() | ||
| .categoryType("네트워크") | ||
| .parent(null) | ||
| .build(); | ||
| em.persist(category); | ||
|
|
||
| Quiz quiz = Quiz.builder() | ||
| .type(QuizFormatType.SUBJECTIVE) | ||
| .question("HTTP와 HTTPS의 차이를 설명하시오.") | ||
| .answer("HTTPS는 보안이 강화된 프로토콜이다.") | ||
| .commentary("HTTPS는 SSL/TLS를 통해 데이터 암호화를 제공한다.") | ||
| .category(category) | ||
| .level(QuizLevel.NORMAL) | ||
| .build(); | ||
| em.persist(quiz); | ||
|
|
||
| Subscription subscription = Subscription.builder() | ||
| .category(category) | ||
| .email("fallback@test.com") | ||
| .startDate(LocalDate.now().minusDays(1)) | ||
| .endDate(LocalDate.now().plusDays(30)) | ||
| .subscriptionType(Set.of()) | ||
| .build(); | ||
| em.persist(subscription); | ||
|
|
||
| User user = User.builder() | ||
| .email("fallback@test.com") | ||
| .name("fallback_user") | ||
| .socialType(SocialType.KAKAO) | ||
| .role(Role.USER) | ||
| .subscription(subscription) | ||
| .build(); | ||
| em.persist(user); | ||
|
|
||
| UserQuizAnswer answer = UserQuizAnswer.builder() | ||
| .user(user) | ||
| .quiz(quiz) | ||
| .userAnswer("HTTPS는 HTTP보다 빠르다.") | ||
| .aiFeedback(null) | ||
| .isCorrect(null) | ||
| .subscription(subscription) | ||
| .build(); | ||
| em.persist(answer); | ||
|
|
||
| // when - AI 피드백 호출 | ||
| var response = aiService.getFeedback(answer.getId()); | ||
|
|
||
| // then - Claude로부터 받은 피드백이 저장됨 | ||
| UserQuizAnswer updated = userQuizAnswerRepository.findById(answer.getId()).orElseThrow(); | ||
|
|
||
| assertThat(updated.getAiFeedback()).isNotBlank(); | ||
| assertThat(updated.getIsCorrect()).isNotNull(); | ||
| System.out.println("📢 Claude 기반 피드백: " + updated.getAiFeedback()); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
폴백 동작을 명시적으로 검증하는 로직을 추가하세요.
현재 테스트는 AI 피드백이 생성되는지만 확인하지만, 실제로 폴백이 발생했는지는 검증하지 않습니다. 테스트 이름에서 약속한 동작을 명시적으로 검증해보세요.
테스트를 더 구체적으로 만들기 위해 다음 방법들을 고려해보세요:
- Mock을 사용하여 OpenAI 클라이언트가 실패하도록 강제
- 로그 캡처를 통해 폴백 로그가 출력되는지 확인
- 응답 내용이 Claude 특성을 가지는지 검증
+@MockBean
+@Qualifier("openAiChatClient")
+private OpenAiChatClient openAiClient;
// 테스트 메서드 내에서
+when(openAiClient.call(anyString(), anyString()))
+ .thenThrow(new RuntimeException("OpenAI 서비스 점검 중"));📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| void openAiFail_thenUseClaudeFeedback() { | |
| // given - 기본 퀴즈, 사용자, 정답 생성 | |
| QuizCategory category = QuizCategory.builder() | |
| .categoryType("네트워크") | |
| .parent(null) | |
| .build(); | |
| em.persist(category); | |
| Quiz quiz = Quiz.builder() | |
| .type(QuizFormatType.SUBJECTIVE) | |
| .question("HTTP와 HTTPS의 차이를 설명하시오.") | |
| .answer("HTTPS는 보안이 강화된 프로토콜이다.") | |
| .commentary("HTTPS는 SSL/TLS를 통해 데이터 암호화를 제공한다.") | |
| .category(category) | |
| .level(QuizLevel.NORMAL) | |
| .build(); | |
| em.persist(quiz); | |
| Subscription subscription = Subscription.builder() | |
| .category(category) | |
| .email("fallback@test.com") | |
| .startDate(LocalDate.now().minusDays(1)) | |
| .endDate(LocalDate.now().plusDays(30)) | |
| .subscriptionType(Set.of()) | |
| .build(); | |
| em.persist(subscription); | |
| User user = User.builder() | |
| .email("fallback@test.com") | |
| .name("fallback_user") | |
| .socialType(SocialType.KAKAO) | |
| .role(Role.USER) | |
| .subscription(subscription) | |
| .build(); | |
| em.persist(user); | |
| UserQuizAnswer answer = UserQuizAnswer.builder() | |
| .user(user) | |
| .quiz(quiz) | |
| .userAnswer("HTTPS는 HTTP보다 빠르다.") | |
| .aiFeedback(null) | |
| .isCorrect(null) | |
| .subscription(subscription) | |
| .build(); | |
| em.persist(answer); | |
| // when - AI 피드백 호출 | |
| var response = aiService.getFeedback(answer.getId()); | |
| // then - Claude로부터 받은 피드백이 저장됨 | |
| UserQuizAnswer updated = userQuizAnswerRepository.findById(answer.getId()).orElseThrow(); | |
| assertThat(updated.getAiFeedback()).isNotBlank(); | |
| assertThat(updated.getIsCorrect()).isNotNull(); | |
| System.out.println("📢 Claude 기반 피드백: " + updated.getAiFeedback()); | |
| } | |
| // --- add to your test class (e.g. above existing @Autowired fields) --- | |
| @MockBean | |
| @Qualifier("openAiChatClient") | |
| private OpenAiChatClient openAiClient; | |
| // --- then update the test method itself --- | |
| @Test | |
| void openAiFail_thenUseClaudeFeedback() { | |
| // given - 기본 퀴즈, 사용자, 정답 생성 | |
| QuizCategory category = QuizCategory.builder() | |
| .categoryType("네트워크") | |
| .parent(null) | |
| .build(); | |
| em.persist(category); | |
| Quiz quiz = Quiz.builder() | |
| .type(QuizFormatType.SUBJECTIVE) | |
| .question("HTTP와 HTTPS의 차이를 설명하시오.") | |
| .answer("HTTPS는 보안이 강화된 프로토콜이다.") | |
| .commentary("HTTPS는 SSL/TLS를 통해 데이터 암호화를 제공한다.") | |
| .category(category) | |
| .level(QuizLevel.NORMAL) | |
| .build(); | |
| em.persist(quiz); | |
| Subscription subscription = Subscription.builder() | |
| .category(category) | |
| .email("fallback@test.com") | |
| .startDate(LocalDate.now().minusDays(1)) | |
| .endDate(LocalDate.now().plusDays(30)) | |
| .subscriptionType(Set.of()) | |
| .build(); | |
| em.persist(subscription); | |
| User user = User.builder() | |
| .email("fallback@test.com") | |
| .name("fallback_user") | |
| .socialType(SocialType.KAKAO) | |
| .role(Role.USER) | |
| .subscription(subscription) | |
| .build(); | |
| em.persist(user); | |
| UserQuizAnswer answer = UserQuizAnswer.builder() | |
| .user(user) | |
| .quiz(quiz) | |
| .userAnswer("HTTPS는 HTTP보다 빠르다.") | |
| .aiFeedback(null) | |
| .isCorrect(null) | |
| .subscription(subscription) | |
| .build(); | |
| em.persist(answer); | |
| // Force OpenAI client to fail so that fallback to Claude is exercised | |
| when(openAiClient.call(anyString(), anyString())) | |
| .thenThrow(new RuntimeException("OpenAI 서비스 점검 중")); | |
| // when - AI 피드백 호출 | |
| var response = aiService.getFeedback(answer.getId()); | |
| // then - Claude로부터 받은 피드백이 저장됨 | |
| UserQuizAnswer updated = userQuizAnswerRepository.findById(answer.getId()).orElseThrow(); | |
| assertThat(updated.getAiFeedback()).isNotBlank(); | |
| assertThat(updated.getIsCorrect()).isNotNull(); | |
| System.out.println("📢 Claude 기반 피드백: " + updated.getAiFeedback()); | |
| } |
🤖 Prompt for AI Agents
In
cs25-service/src/test/java/com/example/cs25service/ai/FallbackAiChatClientIntegrationTest.java
between lines 44 and 99, the test method openAiFail_thenUseClaudeFeedback does
not explicitly verify that the fallback to Claude AI actually occurred. To fix
this, modify the test to mock the OpenAI client to simulate a failure, then
assert that the fallback logic is triggered by checking for specific
fallback-related logs or flags. Additionally, verify that the AI feedback
response contains characteristics unique to Claude to confirm the fallback was
used. This will ensure the test explicitly validates the fallback behavior as
intended.
| } catch (Exception e) { | ||
| throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
예외 처리에서 원본 예외 정보 손실
모든 예외를 AiException으로 변환하면서 원본 예외 정보가 손실됩니다. 디버깅과 로깅을 위해 원본 예외를 포함하는 것을 고려하세요.
다음과 같이 수정하여 원본 예외 정보를 보존하세요:
} catch (Exception e) {
- throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR);
+ throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR, e);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| } catch (Exception e) { | |
| throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR); | |
| } | |
| } catch (Exception e) { | |
| throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR, e); | |
| } |
🤖 Prompt for AI Agents
In
cs25-service/src/main/java/com/example/cs25service/domain/ai/client/OpenAiChatClient.java
around lines 24 to 26, the catch block throws a new AiException without
preserving the original exception, causing loss of the root cause information.
Modify the throw statement to include the caught exception as the cause in the
AiException constructor or as a parameter, so the original exception details are
retained for debugging and logging purposes.
| public ChatClient AichatClient(OpenAiChatModel chatModel) { | ||
| return ChatClient.create(chatModel); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
메서드 이름 규칙 불일치
AichatClient 메서드명이 Java 네이밍 컨벤션에 맞지 않습니다. 메서드명은 소문자로 시작해야 합니다.
다음과 같이 수정하세요:
@Bean
- public ChatClient AichatClient(OpenAiChatModel chatModel) {
+ public ChatClient aiChatClient(OpenAiChatModel chatModel) {
return ChatClient.create(chatModel);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public ChatClient AichatClient(OpenAiChatModel chatModel) { | |
| return ChatClient.create(chatModel); | |
| } | |
| @Bean | |
| public ChatClient aiChatClient(OpenAiChatModel chatModel) { | |
| return ChatClient.create(chatModel); | |
| } |
🤖 Prompt for AI Agents
In
cs25-service/src/main/java/com/example/cs25service/domain/ai/config/AiConfig.java
around lines 21 to 23, the method name `AichatClient` does not follow Java
naming conventions as it starts with an uppercase letter. Rename the method to
start with a lowercase letter, for example, change `AichatClient` to
`aichatClient`.
| @Override | ||
| public String call(String systemPrompt, String userPrompt) { | ||
| try { | ||
| return openAiChatClient.prompt() | ||
| .system(systemPrompt) | ||
| .user(userPrompt) | ||
| .call() | ||
| .content() | ||
| .trim(); | ||
| } catch (Exception e) { | ||
| throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR); | ||
| } | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
예외 처리를 개선하고 로깅을 추가하세요.
현재 모든 예외를 AiException으로 변환하고 있지만, 원본 예외 정보가 손실됩니다. 디버깅과 모니터링을 위해 로깅을 추가하는 것을 권장합니다.
@Override
public String call(String systemPrompt, String userPrompt) {
try {
return openAiChatClient.prompt()
.system(systemPrompt)
.user(userPrompt)
.call()
.content()
.trim();
} catch (Exception e) {
+ log.error("OpenAI 호출 실패: {}", e.getMessage(), e);
throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR);
}
}추가로 로거 필드를 클래스에 추가해야 합니다:
+import lombok.extern.slf4j.Slf4j;
+@Slf4j
@Component
@RequiredArgsConstructor
public class OpenAiChatClient implements AiChatClient {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @Override | |
| public String call(String systemPrompt, String userPrompt) { | |
| try { | |
| return openAiChatClient.prompt() | |
| .system(systemPrompt) | |
| .user(userPrompt) | |
| .call() | |
| .content() | |
| .trim(); | |
| } catch (Exception e) { | |
| throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR); | |
| } | |
| } | |
| import lombok.extern.slf4j.Slf4j; | |
| @Slf4j | |
| @Component | |
| @RequiredArgsConstructor | |
| public class OpenAiChatClient implements AiChatClient { | |
| @Override | |
| public String call(String systemPrompt, String userPrompt) { | |
| try { | |
| return openAiChatClient.prompt() | |
| .system(systemPrompt) | |
| .user(userPrompt) | |
| .call() | |
| .content() | |
| .trim(); | |
| } catch (Exception e) { | |
| log.error("OpenAI 호출 실패: {}", e.getMessage(), e); | |
| throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR); | |
| } | |
| } | |
| } |
🤖 Prompt for AI Agents
In
cs25-service/src/main/java/com/example/cs25service/domain/ai/client/OpenAiChatClient.java
around lines 15 to 27, the current exception handling catches all exceptions and
throws a new AiException without preserving the original exception details or
logging the error. To fix this, add a logger field to the class, log the caught
exception with an appropriate error message inside the catch block, and pass the
original exception as the cause when throwing the new AiException to retain the
stack trace for debugging.
🔎 작업 내용
FallbackAiChatClient중심으로 재구성🛠️ 변경 사항
AiChatClient인터페이스 도입 및 추상화OpenAiChatClient및ClaudeChatClient각각 구현체로 분리FallbackAiChatClient구현@Primary지정으로 기본AiChatClient로 사용됨AiService에서 기존ChatClient대신AiChatClient의존성 주입AiConfig에서 OpenAI / Claude 모델 및 ChatClient 빈 설정 분리FallbackAiChatClientIntegrationTest) 작성FallbackAiChatClientTest) 작성🧩 트러블 슈팅
ClaudeChatClient,OpenAiChatClient모두ChatClient빈을 가짐Summary by CodeRabbit
closed #124