feat: AI 로직 개선 및 서킷 브레이커를 통한 LLM API 이중화 구현#180
Conversation
- AI 서버가 필요로 하는 정보와 응답 내용이 변경되었다. 응답은 순수 채팅만 올 수 있어 이에 맞게 수정하였고, 응답에서 원본 이미지 여부를 추가했다.
- SSE 요청을 보낼 때 DTO와 DLQ 처리할 떄 DTO가 다른 문제 수정
- LLM 이중화를 위해 API사를 두개로 확장했다.
- 서킷 브레이커를 사용해 API에서 장애가 발생하면 대체 API를 호출하게 하였다. - LLM 요청이 길어 이를 비동기로 끊어냈다. 이에 따라 비동기 스레드 풀을 변경하였다.
|
Caution Review failedThe pull request is closed. WalkthroughLLM 호출을 프로바이더화하고 CircuitBreaker(Resilience4j)와 Claude 폴백을 추가했으며, 이미지 요청/응답 DTO를 재구성하고 비동기 실행기(이미지·GPT 전용) 및 에러코드·속성 변경을 적용했습니다. 리스너는 이미지 생성 성공/실패로 분기해 저장·SSE 전송 경로를 분리합니다. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant C as Client
participant S as AiChatService
participant CB as CircuitBreaker
participant G as GptProvider
participant A as ClaudeProvider
C->>S: prompt(systemPrompt, userPrompt)
activate S
S->>CB: 요청(데코레이트된 Supplier)
CB->>G: GptProvider.prompt()
alt GPT 성공
G-->>CB: 응답 텍스트
CB-->>S: 정상 결과
S-->>C: 텍스트 반환
else GPT 실패/차단
CB-->>S: 예외
S->>A: ClaudeProvider.prompt() (폴백)
A-->>S: 폴백 텍스트
S-->>C: 폴백 텍스트 반환
end
deactivate S
sequenceDiagram
autonumber
participant MQ as MessageQueue
participant L as AiImageCreatedListener
participant R as Repository
participant SSE as AiResponseSseService
MQ-->>L: AiImageResponseMessageDto
activate L
L->>R: 메시지/방 조회
alt message.isImageGenerated == true
L->>R: AiChatImageEntity 저장 (imageName, extension, fromStyleImage 등)
L->>R: AiChatMessageEntity 저장(이미지 포함)
L->>SSE: 성공 SSE 전송 (ChatMessageResponse with imageUrl)
else 이미지 미생성
L->>R: 실패 응답 메시지 저장 (textContext 저장, image 없음)
L->>SSE: 실패 SSE 전송 (ChatMessageResponse without image)
end
deactivate L
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (4)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 13
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/AiImageCreatedListener.java (1)
98-108: 응답 메시지의 senderType 오설정(USER).AI가 생성한 응답인데
SenderType.USER로 저장 중입니다. UI/로직 혼선을 초래합니다.SenderType.AI로 수정하세요.- .senderType(SenderType.USER) + .senderType(SenderType.AI)src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiServerService.java (3)
100-102: LLM 분류 결과 신뢰 문제:valueOf직호출 취약.모델이 공백/따옴표/소문자/설명 텍스트를 섞어 반환하면
IllegalArgumentException로 크래시합니다. 정규화/화이트리스트 후 파싱하세요.- String category = aiChatService.prompt(systemPrompt, message); - return RequestCategory.valueOf(category); + String category = aiChatService.prompt(systemPrompt, message); + String normalized = category == null ? "" : category.trim().replaceAll("^[\"']|[\"']$", "").toUpperCase(); + if (!"IMAGE_GENERATION".equals(normalized) && !"CHAT_GENERATION".equals(normalized)) { + log.warn("Unexpected classification result: '{}', fallback to CHAT_GENERATION", category); + normalized = "CHAT_GENERATION"; + } + return RequestCategory.valueOf(normalized);
184-188: 대표 이미지 조회 시 빈 목록 처리 누락.
findAllByPostId(postId).get(0)는 빈 결과에서IndexOutOfBoundsException발생합니다. 예외 처리/대체 경로가 필요합니다.- PostImageEntity postImage = postImageEntityRepository.findAllByPostId(postId).get(0); + List<PostImageEntity> images = postImageEntityRepository.findAllByPostId(postId); + if (images.isEmpty()) { + throw new AppException(ErrorCode.POST_IMAGE_NOT_FOUND); + } + PostImageEntity postImage = images.get(0);
248-264: AI 컨텍스트 메시지의 시간 순서 보장 필요.
findTopNBy...OrderByMessageOrderDesc가 최신→과거 순이면 LLM에 역순 컨텍스트가 전달됩니다. 응답 품질 저하 우려가 있어 정순으로 정렬해 전달하세요.- return aiChatMessageRepository - .findTopNByUserIdAndPostIdOrderByMessageOrderDesc(userId, postId, pageable); + List<AiChatMessageEntity> list = aiChatMessageRepository + .findTopNByUserIdAndPostIdOrderByMessageOrderDesc(userId, postId, pageable); + java.util.Collections.reverse(list); + return list;추가 import:
import java.util.Collections;
🧹 Nitpick comments (36)
src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/llm/gpt/ChatGPTResponse.java (5)
5-9: 불필요한 Lombok @Getter import 제거클래스에
@Data를 사용하므로@Getterimport는 중복입니다.import java.util.List; import lombok.AllArgsConstructor; import lombok.Data; -import lombok.Getter; import lombok.NoArgsConstructor;
23-29: Choice: Lombok 중복 제거 및 SnakeCase/ignoreUnknown 적용
@Getter중복 제거.finishReason등 camelCase 필드가 snake_case(JSON)와 자동 매핑되도록 보강.-@Data -@Getter +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public static class Choice { private int index; private Message message; private String finishReason; }
31-36: Message: 균일한 매핑 전략 적용 + 주석 표현 완화
- 다른 클래스와 동일하게
@JsonIgnoreProperties/@JsonNaming적용을 권장합니다.- Line 34 주석은 용도 특정이 강합니다. 일반적으로 모델 응답 텍스트(또는 JSON)일 수 있으므로 표현을 완화해 주세요.
-@Data +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public static class Message { private String role; - private String content; // JSON 형식의 감정 분석 결과를 담음 + private String content; // 모델 응답 텍스트(또는 JSON) 직렬화 문자열 private String refusal; }
38-59: Usage 및 세부 DTO: SnakeCase/ignoreUnknown 적용OpenAI는 usage 세부 필드를 자주 추가/변경합니다. 알 수 없는 필드 무시와 스네이크케이스 매핑을 클래스별로 명시하면 회귀 위험을 줄일 수 있습니다.
-@Data +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public static class Usage { private int promptTokens; private int completionTokens; private int totalTokens; private PromptTokensDetails promptTokensDetails; private CompletionTokensDetails completionTokensDetails; - @Data + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public static class PromptTokensDetails { private int cachedTokens; private int audioTokens; } - @Data + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public static class CompletionTokensDetails { private int reasoningTokens; private int audioTokens; private int acceptedPredictionTokens; private int rejectedPredictionTokens; } }
3-9: Jackson 어노테이션 추가에 필요한 import 보강위 제안들을 적용하려면 아래 import가 필요합니다.
import java.util.List; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor;src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/llm/gpt/ChatGPTRequest.java (1)
5-9: DTO 검증·불변성 강화 제안아래와 같이 보강을 제안합니다.
package hanium.modic.backend.domain.ai.aiServer.dto.llm.gpt; import java.util.List; +import java.util.Objects; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; public record ChatGPTRequest( - String model, - List<ChatGPTMessage> messages + @NotBlank String model, + @NotEmpty @Valid List<ChatGPTMessage> messages ) { + public ChatGPTRequest { + Objects.requireNonNull(model, "model"); + messages = List.copyOf(Objects.requireNonNull(messages, "messages")); + } }src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/llm/gpt/ChatGPTMessage.java (1)
3-7: role 문자열 오타 방지·유효값 보장 제안 (비침투, 와이어 포맷 유지)
role이String이라 오타/스펙 이탈 위험이 있습니다. 레코드의 compact constructor로null및 허용값 검증을 추가하면 호환성은 유지하면서 방어적입니다. (허용: system/user/assistant/tool)public record ChatGPTMessage( - String role, + String role, String content ) { + public ChatGPTMessage { + java.util.Objects.requireNonNull(role, "role"); + java.util.Objects.requireNonNull(content, "content"); + if (!java.util.Set.of("system","user","assistant","tool").contains(role)) { + throw new IllegalArgumentException("Unsupported role: " + role); + } + } }src/main/java/hanium/modic/backend/domain/ai/aiChat/dto/ChatMessageRequest.java (1)
9-15: DTO 수준의 빈 값 검증 제거에 따른 경계 검증 재확인@notblank 제거로 공백 문자열(" ")이 통과할 수 있습니다. 서비스 계층에서 “텍스트 또는 이미지 중 하나는 유효” 검증을 수행한다면, trim 기반 공백 처리까지 포함되는지 확인해 주세요. 필요 시 DTO에 최대 길이 제한만 별도로 두는 것도 방법입니다.
예: service 내부에서
- null 또는 공백-only 문자열을 동일하게 빈 값으로 취급
- 이미지 ID 존재 시 텍스트는 빈 값 허용
src/main/java/hanium/modic/backend/common/async/AsyncConfig.java (2)
55-69: Bean 이름과 메서드 이름 불일치@bean(name = "llmTaskExecutor")인데 메서드명이 gptTaskExecutor입니다. 혼동 방지를 위해 메서드명을 llmTaskExecutor로 맞추는 것이 좋습니다.
- @Bean(name = "llmTaskExecutor") - public ThreadPoolTaskExecutor gptTaskExecutor() { + @Bean(name = "llmTaskExecutor") + public ThreadPoolTaskExecutor llmTaskExecutor() {
39-53: CallerRunsPolicy 사용 시 호출 스레드 블로킹 주의포화 시 호출 스레드에서 실행됩니다. 웹 요청/리액터 이벤트 루프에서 호출될 가능성이 있는 경로라면 지연을 유발합니다. 이미지 삭제 경로가 컨트롤러 스레드에서 직접 호출되지 않는지 확인해 주세요.
src/main/java/hanium/modic/backend/domain/image/util/S3ImageUtil.java (2)
70-74: 전용 실행기 지정 적절함이미지 삭제를 전용 풀로 분리한 점 좋습니다. 예외는 @async(void) 메서드에서 호출자에게 전파되지 않으니, 모니터링/재시도 정책이 필요한지 검토해 주세요(현재 AsyncUncaughtExceptionHandler 로깅만 수행).
필요 시 실패 이벤트를 메트릭/로깅에 추가하거나, 호출자가 필요로 하면 CompletableFuture 반환으로 전환을 고려하세요.
88-112: 배치 삭제에서 입력 검증 완료 확인개별 경로 검증 로직은 적절합니다. 대량 삭제 실패 시 부분 성공/롤백 전략이 없는 점만 인지해 주세요(S3는 비원자적). 운영상 요구가 있다면 실패 키 목록 로깅/반환을 고려할 수 있습니다.
src/main/java/hanium/modic/backend/common/error/ErrorCode.java (1)
92-93: 에러 메시지 문구 다듬기자연스러운 문장으로 미세 조정 제안드립니다.
- EMPTY_CHAT_MESSAGE_EXCEPTION(HttpStatus.BAD_REQUEST, "AC-002", "채팅 메시지랑 이미지가 모두 비어있습니다."), + EMPTY_CHAT_MESSAGE_EXCEPTION(HttpStatus.BAD_REQUEST, "AC-002", "채팅 메시지와 이미지가 모두 비어 있습니다."),src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/AiProvider.java (1)
3-5: 동기식 반환 타입이 비동기/스트리밍 전략과 부조화WebClient 비동기·SSE 스트리밍을 도입했다면 동기 String 반환은 제약이 큽니다. 최소 Mono 오버로드 또는 스트리밍 메서드 도입을 고려해 주세요.
public interface AiProvider { - String prompt(String systemPrompt, String userPrompt); + // 동기(레거시) 경로 + String prompt(String systemPrompt, String userPrompt); + // 비동기 경로 + reactor.core.publisher.Mono<String> promptAsync(String systemPrompt, String userPrompt); + // (선택) 토큰 스트리밍 + // reactor.core.publisher.Flux<String> promptStream(String systemPrompt, String userPrompt); }src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/llm/claude/ClaudeRequest.java (1)
5-10: 옵셔널 필드 직렬화 및 자바 네이밍 정합성
- max_tokens를 Integer로 두면 미설정 시 필드 생략이 가능합니다.
- @JsonInclude로 null 필드 제외 직렬화 권장.
- 자바 쪽 가독성을 위해 maxTokens + @JsonProperty("max_tokens") 패턴도 고려해 볼 수 있습니다.
+import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +@JsonInclude(JsonInclude.Include.NON_NULL) public record ClaudeRequest( String model, - int max_tokens, + @JsonProperty("max_tokens") Integer max_tokens, String system, List<ClaudeMessage> messages ) { }src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/llm/claude/ClaudeResponse.java (2)
10-14: ClaudeContent 타입 중복 정의 — 혼동 방지 리네이밍/공용화 제안동일 구조의
ClaudeContent가ClaudeMessage에도 존재합니다. 중복은 유지보수/혼동 비용을 키웁니다. 공용 DTO로 분리하거나 최소한ClaudeTextContent등으로 명확히 구분해 주세요.
5-9: 전달 필드 확장 대비: 알 수 없는 필드 무시 설정 권장업스트림 스키마 확장 시 역직렬화 안전성을 위해
@JsonIgnoreProperties(ignoreUnknown = true)추가를 권장합니다.적용 예:
package hanium.modic.backend.domain.ai.aiServer.dto.llm.claude; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import java.util.List; +@JsonIgnoreProperties(ignoreUnknown = true) public record ClaudeResponse( String id, String type, List<ClaudeContent> content ) {src/main/java/hanium/modic/backend/common/config/Resilience4jConfig.java (1)
26-31: recordResult 조건이 실효성이 없음CB가 감시하는 리턴값은 최종 답변 문자열(String)인데,
"\"error\""포함 여부로 실패를 판정하는 현재 로직은 사실상 동작하지 않습니다. 예외 기반 판정만 남기거나 별도의 응답 래퍼로 전환하세요.적용 예(단순 제거):
- .recordResult(response -> { - if (response == null) - return true; // 실패 - return response.toString().contains("\"error\""); // payload 안에 "error" 있으면 실패 - })src/main/java/hanium/modic/backend/common/property/property/AiProperties.java (1)
14-28: 필수 설정값 검증 추가 제안(@validated, @notblank)키/모델 누락 시 런타임 오류를 예방하려면 부트 부팅 시점 검증을 권장합니다.
적용 예:
-import lombok.Getter; -import lombok.Setter; +import lombok.Getter; +import lombok.Setter; +import org.springframework.validation.annotation.Validated; +import jakarta.validation.constraints.NotBlank; -@ConfigurationProperties(prefix = "ai") +@ConfigurationProperties(prefix = "ai") +@Validated public class AiProperties { @@ public static class OpenAi { - private String apiKey; - - private String model; + @NotBlank private String apiKey; + @NotBlank private String model; } @@ public static class Claude { - private String apiKey; - private String model; + @NotBlank private String apiKey; + @NotBlank private String model; }src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/llm/claude/ClaudeMessage.java (2)
9-12: ClaudeContent 중복 정의
ClaudeResponse에도 동일 레코드가 있습니다. 공용 타입으로 통합하거나 네이밍을 분리해 혼동을 줄이세요.
6-7: role 스트링 → enum 전환 검토타이포 방지 및 스키마 안정성을 위해
enum Role { user, assistant, system }등으로 강타이핑을 권장합니다.src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/GptProvider.java (1)
61-62: 호출 레벨 타임아웃 추가네트워크/서버 지연 대비로 퍼블리셔 타임아웃을 걸어주세요.
- .bodyToMono(String.class) - .block(); + .bodyToMono(String.class) + .timeout(java.time.Duration.ofSeconds(60)) + .block();src/main/java/hanium/modic/backend/domain/ai/aiChat/service/AiChatMessageService.java (3)
95-103: 변수명 오타(nit): textContext → textContent의미상
textContent가 일관됩니다. 가독성 위해 수정 권장.- private void validateRequestMessageNotEmpty(ChatMessageRequest request) { - boolean isEmptyTextContext = request.textContent() == null || request.textContent().trim().isEmpty(); + private void validateRequestMessageNotEmpty(ChatMessageRequest request) { + boolean isEmptyTextContent = request.textContent() == null || request.textContent().trim().isEmpty(); boolean isEmptyImageId = request.aiChatImageId() == null; - if (isEmptyTextContext && isEmptyImageId) { + if (isEmptyTextContent && isEmptyImageId) { throw new AppException(ErrorCode.EMPTY_CHAT_MESSAGE_EXCEPTION); } }
89-91: 트랜잭션 내 외부 호출 — 사가/사후커밋 전송 권장DB 트랜잭션 내에서 외부 처리(메시지 발행 등)를 호출하면 롤백 시 일관성 문제가 납니다. 커밋 후 전송으로 전환하세요.
적용 방향:
@TransactionalEventListener(phase = AFTER_COMMIT)또는TransactionSynchronizationManager.registerSynchronization(...)로 커밋 후aiServerService.processAiRequest(...)호출
82-85: 중복 조회 최소화이미 존재 검증(
validateAiChatImageExistence) 후findById를 다시 호출합니다. 단일 조회로 합치면 I/O 1회 절감됩니다.src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/ClaudeProvider.java (2)
68-69: 호출 레벨 타임아웃 추가퍼블리셔 타임아웃으로 지연 방지.
- .bodyToMono(String.class) - .block(); + .bodyToMono(String.class) + .timeout(java.time.Duration.ofSeconds(60)) + .block();
28-29: 상수 설정 외부화 제안
MAX_TOKENS를 프로퍼티(ai.claude.max-tokens)로 외부화하면 운영 중 조정이 수월합니다.src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/AiImageResponseMessageDto.java (1)
5-7: 널 필드 직렬화 제외로 페이로드 단순화이미지 관련 필드는 조건부입니다.
@JsonInclude(Include.NON_NULL)로 불필요한 null 필드 송출을 줄이세요.적용 예:
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; @JsonInclude(Include.NON_NULL) public record AiImageResponseMessageDto( ... ) { }src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiChatService.java (1)
51-56: 논블로킹 주장과 달리 동기(block) 경로.Provider가 WebClient
.block()을 사용하므로 실제로는 동기 I/O입니다. 스레드풀 고갈 위험이 있으니(특히 llmTaskExecutor) 타임아웃/버크헤드 적용 또는 진짜 논블로킹/스트리밍 전환을 고려하세요.src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/AiImageCreatedListener.java (2)
55-59: 오류 로그 메시지의 ID 잘못 기재.채팅룸 조회 실패 시
aiChatImageId를 로그에 찍고 있습니다. 실제로는aiChatRoomId를 찍어야 추적 가능합니다.- log.error("[AI 이미지 처리 실패] AI 채팅방을 찾을 수 없습니다. aiChatImageId: {}", requestChatMessage.getAiChatImageId()); + log.error("[AI 이미지 처리 실패] AI 채팅방을 찾을 수 없습니다. aiChatRoomId: {}", requestChatMessage.getAiChatRoomId());
118-121: SSE 전송 전 예외 처리/관찰성 보강 제안.SSE 전송 실패 시 메시지가 유실될 수 있습니다. 전송 시도/실패 카운터 및 재시도(최소 1회) 또는 대기열 보강을 고려하세요.
src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/AiImageRequestMessageDto.java (2)
13-19: 필드명 가독성(nit): imagesPath → imagePaths.복수형에 맞게
imagePaths가 직관적입니다. JSON 스키마 소비자 영향이 있다면 호환 alias를 고려하세요.
32-52: JSON 페이로드 최소화/일관성 제안.null 필드 제거를 위해
@JsonInclude(Include.NON_NULL)적용을 고려하세요(레코드/내부 레코드에 부착).src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiServerService.java (3)
168-173: 원문 응답 전체 로깅 주의.JSON 파싱 실패 시 원문 전체를 에러 로그로 남깁니다. PII/프롬프트 유출 위험이 있어 길이 제한/샘플링/마스킹을 권장합니다.
139-143: TODO 주석 불일치.이미 SSE 전송으로 동작 중입니다. TODO 갱신/제거하세요.
151-161: LLM 지시 강제력 보강 제안."JSON만 출력" 유도를 위해 system prompt 외에 파서가 관대한 정규화/재시도(파싱 실패 시 재프롬프트) 전략을 고려하세요.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (27)
build.gradle(1 hunks)src/main/java/hanium/modic/backend/common/amqp/service/MessageQueueService.java(0 hunks)src/main/java/hanium/modic/backend/common/async/AsyncConfig.java(3 hunks)src/main/java/hanium/modic/backend/common/config/Resilience4jConfig.java(1 hunks)src/main/java/hanium/modic/backend/common/error/ErrorCode.java(1 hunks)src/main/java/hanium/modic/backend/common/property/property/AiProperties.java(1 hunks)src/main/java/hanium/modic/backend/domain/ai/aiChat/dto/ChatMessageRequest.java(1 hunks)src/main/java/hanium/modic/backend/domain/ai/aiChat/service/AiChatMessageService.java(2 hunks)src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/AiChatRequestDto.java(0 hunks)src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/AiImageRequestMessageDto.java(1 hunks)src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/AiImageResponseMessageDto.java(1 hunks)src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/ImageResultResponse.java(0 hunks)src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/llm/claude/ClaudeMessage.java(1 hunks)src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/llm/claude/ClaudeRequest.java(1 hunks)src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/llm/claude/ClaudeResponse.java(1 hunks)src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/llm/gpt/ChatGPTMessage.java(1 hunks)src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/llm/gpt/ChatGPTRequest.java(1 hunks)src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/llm/gpt/ChatGPTResponse.java(1 hunks)src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/llm/gpt/GptChatResponseDto.java(1 hunks)src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/AiImageCreatedListener.java(5 hunks)src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/DlqListener.java(0 hunks)src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiChatService.java(1 hunks)src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiServerService.java(11 hunks)src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/AiProvider.java(1 hunks)src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/ClaudeProvider.java(1 hunks)src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/GptProvider.java(1 hunks)src/main/java/hanium/modic/backend/domain/image/util/S3ImageUtil.java(2 hunks)
💤 Files with no reviewable changes (4)
- src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/DlqListener.java
- src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/ImageResultResponse.java
- src/main/java/hanium/modic/backend/common/amqp/service/MessageQueueService.java
- src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/AiChatRequestDto.java
🧰 Additional context used
🧬 Code graph analysis (3)
src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiChatService.java (2)
src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/ClaudeProvider.java (1)
Service(20-82)src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/GptProvider.java (1)
Service(18-72)
src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/GptProvider.java (2)
src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/ClaudeProvider.java (1)
Service(20-82)src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiChatService.java (1)
Slf4j(16-61)
src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/ClaudeProvider.java (2)
src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/GptProvider.java (1)
Service(18-72)src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiChatService.java (1)
Slf4j(16-61)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (13)
src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/llm/gpt/ChatGPTResponse.java (2)
1-1: 확인 완료 — 잔존 참조 없음hanium.modic.backend.domain.ai.aiServer.dto.chatGpt를 참조하는 코드는 없습니다. 현재 관련 import는 src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/GptProvider.java (12–14행)에서 hanium.modic.backend.domain.ai.aiServer.dto.llm.gpt.*를 사용하고 있습니다. 향후 패키지 소문자화(aiServer → ai.server) 리팩토링 검토 바랍니다.
10-14: Lombok 중복 제거 + JSON 스네이크케이스 매핑/내구성 강화 제안File: src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/llm/gpt/ChatGPTResponse.java (Lines: 10-14)
- @DaTa에 @Getter가 포함되어 있어 중복입니다 — @Getter 제거 제안.
- OpenAI 응답은 snake_case 필드가 많아 DTO에 @JsonIgnoreProperties(ignoreUnknown = true) 및 @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) 추가로 글로벌 설정 의존도를 낮추고 변경에 견고하게 만드세요.
- 실행하신 rg 검색이 빈 결과를 반환했습니다(리포지토리에서 글로벌 SNAKE_CASE 설정 미발견). 팀에 글로벌 설정이 있다면 위 어노테이션은 선택적이니 확인 필요.
적용 예시(diff):
@Data @NoArgsConstructor @AllArgsConstructor -@Getter +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public class ChatGPTResponse {필요 import:
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming;src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/llm/gpt/ChatGPTRequest.java (1)
1-1: 패키지 네임스페이스 정리 확인 — 구 패키지 참조 없음rg 검사 결과, 구 패키지 경로(hanium.modic.backend.domain.ai.aiServer.dto.chatGpt)를 참조하는 파일 없음. ChatGPT 관련 클래스들은 llm.gpt 패키지에서 사용 중(예: src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/GptProvider.java).
src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/llm/gpt/ChatGPTMessage.java (1)
1-1: 패키지 이동 OK — 로컬에서 잔여 import/FQCN 및 @JsonTypeInfo 사용 여부 확인파일: src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/llm/gpt/ChatGPTMessage.java
리포지토리 검색이 실행되지 않아 자동 확인 불가. 로컬에서 아래 명령을 실행해 잔여 FQCN/임포트와 클래스명 기반 직렬화(@JsonTypeInfo/@JsonTypeName, JsonTypeInfo.Id.CLASS 등) 사용 여부를 확인하십시오.
# (리포지토리 루트에서 실행) git grep -n "hanium.modic.backend.domain.ai.aiServer.dto.chatGpt.ChatGPTMessage" || true git grep -n "import hanium.modic.backend.domain.ai.aiServer.dto.chatGpt.ChatGPTMessage;" || true # ripgrep 사용(설정에 따라 --type 필터를 피함) rg -n -S -C2 'hanium\.modic\.backend\.domain\.ai\.aiServer\.dto\.chatGpt\.ChatGPTMessage' || true rg -n -S -C2 '\bChatGPTMessage\b' -g '!**/target/**' -g '!**/build/**' -g '!**/node_modules/**' || true # 직렬화 어노테이션 검색 rg -n -S -C2 '@JsonTypeInfo\(' || true rg -n -S -C2 'JsonTypeInfo\.Id\.CLASS' || true rg -n -S -C2 '@JsonTypeName' || truesrc/main/java/hanium/modic/backend/domain/ai/aiServer/dto/llm/gpt/GptChatResponseDto.java (1)
1-4: 패키지 리팩터링 일관성 확인 필요패키지 이동은 적절합니다. 종속 코드가 모두 새 패키지로 따라왔는지 최종 확인 필요합니다. 실행한 rg가 "No files were searched"를 반환해 자동 검증에 실패했습니다. 저장소 루트에서 아래 명령 중 하나를 실행해 출력 결과를 코멘트에 첨부하세요.
# 권장: ripgrep(숨김/무시 파일 포함) rg -n --hidden -S -uu --glob '!**/build/**' 'hanium\.modic\.backend\.domain\.ai\.aiServer\.dto\.chatGpt|ChatGPTResponse|GptChatResponseDto' || true # 대안: git으로 추적 파일만 검색 git --no-pager grep -n -e 'hanium.modic.backend.domain.ai.aiServer.dto.chatGpt' -e 'ChatGPTResponse' -e 'GptChatResponseDto' || truebuild.gradle (1)
92-95: 중복 선언 아님:resilience4j-spring-boot3:2.2.0는 circuitbreaker 모듈을 전이 의존하지 않으므로, 별도 선언이 필요합니다.Likely an incorrect or invalid review comment.
src/main/java/hanium/modic/backend/common/config/Resilience4jConfig.java (1)
23-25: 4xx까지 실패 집계 정책 확인 필요현재 Provider에서 4xx/5xx를 모두
AppException으로 래핑하므로 4xx도 실패로 집계됩니다. 사용자 입력 유발 4xx(금칙어, 토큰 초과 등)를 회로차단 트립 원인으로 볼지 정책 확인 바랍니다.src/main/java/hanium/modic/backend/common/property/property/AiProperties.java (1)
1-1: 패키지 경로 확인
property.property이중 패키지 네이밍이 의도인지 확인 부탁드립니다.src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/GptProvider.java (1)
45-60: 4xx/5xx 동일 처리 정책 확인현재 4xx/5xx 모두
AI_SERVER_ERROR로 매핑됩니다. 4xx는 입력 오류일 수 있어 재시도/CB 정책에 혼선을 줍니다. 분기(예: 4xx→클라이언트 오류) 필요 여부 확인 바랍니다.src/main/java/hanium/modic/backend/domain/ai/aiChat/service/AiChatMessageService.java (1)
50-52: 빈 메시지 가드 추가 — 좋습니다텍스트/이미지 동시 공백 차단이 명확합니다.
src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/ClaudeProvider.java (1)
35-37: 확인 완료 —anthropic-version: 2023-06-01유지하세요.공식 Anthropic Messages API 문서와 Claude 3.5 Sonnet 안내에 따르면 해당 값이 권장되며, 2025-09-22 기준으로 유효합니다.
src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/AiImageCreatedListener.java (1)
111-113: 요청 메시지 상태 업데이트 값 검증 필요.응답 처리 후 요청 메시지를
AiImageStatus.REQUEST로 돌립니다. 의도한 상태 전이(REQUESTING→RESPONSE 등)와 맞는지 확인 필요합니다. 잘못된 상태면 재시도/통계에 오동작이 납니다.상태 전이 다이어그램 공유 가능하시면 그에 맞춰 패치 제안 드리겠습니다.
src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/AiImageRequestMessageDto.java (1)
24-26: role 값 스키마 확인 필요(SenderType vs "user/assistant").주석은 "user/assistant"인데 타입은 내부
SenderType(AI/USER)로 보입니다. 소비 측(이미지 생성기)이 "assistant"를 기대하면 불일치로 오작동합니다. 매핑 규칙을 명확히 하세요.역직렬화 스키마에 맞춰 매핑이 필요하면 알려주세요. 변환 유틸 추가 가능합니다.
src/main/java/hanium/modic/backend/common/async/AsyncConfig.java
Outdated
Show resolved
Hide resolved
src/main/java/hanium/modic/backend/common/config/Resilience4jConfig.java
Show resolved
Hide resolved
src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/AiImageResponseMessageDto.java
Show resolved
Hide resolved
src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/AiImageCreatedListener.java
Show resolved
Hide resolved
src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiChatService.java
Show resolved
Hide resolved
src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiServerService.java
Show resolved
Hide resolved
src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/ClaudeProvider.java
Show resolved
Hide resolved
src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/ClaudeProvider.java
Show resolved
Hide resolved
src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/GptProvider.java
Show resolved
Hide resolved
src/main/java/hanium/modic/backend/domain/ai/aiServer/service/llm/GptProvider.java
Show resolved
Hide resolved
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
src/main/java/hanium/modic/backend/common/config/Resilience4jConfig.java (2)
23-23: 주석 오기 수정 제안‘SocketTime’ → ‘SocketTimeout’ 이 맞습니다. 또한 ‘ConnectTimeoutException’ 등으로 구체화하면 가독성이 좋아집니다.
- e instanceof java.io.IOException // SocketTime, ConnectTimeout, IOException 발생 시 실패로 간주 + e instanceof java.io.IOException // SocketTimeout, ConnectTimeoutException 등 네트워크/타임아웃 계열 예외를 실패로 간주
33-34: 구성 외부화 및 인스턴스 네이밍 권장운영/스테이징 튜닝을 위해 application.yml로 외부화하고, primary/secondary를 명시적 인스턴스로 두면 관측/메트릭이 선명해집니다.
예시(application.yml):
resilience4j: circuitbreaker: configs: llm-default: sliding-window-type: COUNT_BASED sliding-window-size: 20 minimum-number-of-calls: 10 failure-rate-threshold: 50 wait-duration-in-open-state: 60s permitted-number-of-calls-in-half-open-state: 10 automatic-transition-from-open-to-half-open-enabled: true instances: llm-primary: base-config: llm-default llm-secondary: base-config: llm-default
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
src/main/java/hanium/modic/backend/common/config/Resilience4jConfig.java(1 hunks)
🔇 Additional comments (1)
src/main/java/hanium/modic/backend/common/config/Resilience4jConfig.java (1)
18-21: CB 파라미터가 PR 요약과 불일치 — 설계값(60s/20/Half‑Open 10)으로 수정 필요설계(요약)와 상이합니다. 60s, window 20, Half‑Open 허용 10회, 자동 Half‑Open 전환을 반영해 주세요.
- .failureRateThreshold(50) // 실패율 50% 이상이면 Open - .slidingWindowSize(10) // 최근 10번 요청 기준 - .minimumNumberOfCalls(10) // 최소 10번 이후부터 판단 - .waitDurationInOpenState(Duration.ofHours(1)) // Open 유지 시간 + .failureRateThreshold(50) // 실패율 50% 이상이면 Open + .slidingWindowSize(20) // 최근 20번 요청 기준 + .minimumNumberOfCalls(10) // 최소 10번 이후부터 판단 + .waitDurationInOpenState(Duration.ofSeconds(60)) // Open 유지 시간 + .permittedNumberOfCallsInHalfOpenState(10) // Half-Open 허용 호출 수 + .automaticTransitionFromOpenToHalfOpenEnabled(true) // 60초 후 자동 Half-Open 시도
개요
LLM 서비스의 안정성과 가용성을 향상시키기 위해 GPT와 Claude API의 이중화를 구현하고, 서킷 브레이커 패턴을 통해 장애 상황에서의 자동 페일오버를 지원하게 했습니다.
구현 내용
LLM API 이중화 시스템
문제 상황
GPT API를 활용하여 채팅을 하였으나, 종종 GPT에 장애가 발생하여 먹통이 되는 문제가 발생했다.
GPT와의 채팅을 통한 이미지 생성 기능이 핵심 기능이라 장애가 나면 서비스에 큰 지장이 생기므로 이를 보완할 필요가 생겼다.
이에 대한 해결책을 알아보는 도중
https://kdh1226.tistory.com/33
해당 글을 통해 Circuit Breaker를 활용한 이중화 방식을 알게 되었다.
이를 통해 서킷 브레이커를 도입하고자 하였으며,
다만 장애상황을 어떻게 판단할지가 관건이었다.
장애상황은 어떻게 판단해야할까?
GPT는 요청에 따라 응답시간이 제각각이라 타임아웃 판단이 어려웠다.
따라서 타임아웃을 제외한 에러를 처리하고자 했다.
이에 따라
등을 고려했다.
GPT 에러코드를 찾아본 결과
등의 에러코드가 존재했다.
기타 통신 자체가 안되어 네트워크 장애가 발생할 수도 있었다.
이에 따라
CustomException과 IOException이 일정 이상 발생하면 CircuitBreaker가 동작되게 하였다.
적용
최근 10번 중 5번 이상 실패하면 서킷 브레이커가 동작하게 하였다.
그리고 특성 상 GPT부하가 있으면 장기간 유지되므로 1시간 텀을 둬서 Half-Open으로 전환되게 했다.
테스트를 위해 에러가 1개 났을 때 Circuit Breaker가 동작하게 하였다.

로그를 확인해보니 정상적으로 Circuit Breaker가 동작했다.
결과
기타 변경 사항
Summary by CodeRabbit