Skip to content

Conversation

@kgy1008
Copy link
Member

@kgy1008 kgy1008 commented Sep 7, 2025

📣 Jira Ticket

EDMT-406

👩‍💻 작업 내용

OpenAI를 통한 생기부 응답 생성 시, 단계별 진행 상황을 전송하는 기능을 추가하였습니다.

📸 스크린 샷

image

Summary by CodeRabbit

  • 신기능
    • AI 버전 생성의 실시간 진행 알림 추가: 시작/완료 PROGRESS 메시지 제공
    • PROGRESS와 RESPONSE를 구분하는 구조화된 이벤트 스트림 제공(타입별 페이로드, 이벤트명 변경)
  • 리팩터링
    • SSE 페이로드를 통일된 이벤트 envelope로 표준화; taskId가 문자열로 변경되어 스트리밍 식별 일관성 향상
  • 작업
    • 학생 기록 AI 작업의 상태 컬럼 제거 및 상태 관리 로직 정리
  • 기타
    • 잘못된 taskId 입력에 대한 명확한 오류 코드 추가 (숫자 형식 예외)

@kgy1008 kgy1008 self-assigned this Sep 7, 2025
@coderabbitai
Copy link

coderabbitai bot commented Sep 7, 2025

Walkthrough

AI 작업 상태 DB 컬럼과 관련 enum을 제거하고 taskId 타입을 숫자에서 문자열로 변경했으며, SSE 전송을 위한 DTO(SSEMessage, AIProgressMessage)와 SSEChannelManager/A IEventListener 연동(진행/응답 메시지 표준화) 및 SQS idempotencyKey 도입을 추가했습니다.

Changes

Cohort / File(s) Summary
DB 마이그레이션: status 컬럼 제거
edukit-api/src/main/resources/db/migration/V8__Remove_student_record_ai_task_columns.sql
student_record_ai_task 테이블에서 status 컬럼을 제거하는 DDL (ALTER TABLE ... DROP COLUMN status;).
엔티티: DB 상태 필드 제거
edukit-core/src/main/java/com/edukit/core/studentrecord/db/entity/StudentRecordAITask.java
엔티티에서 status 필드·게터·빌더 항목 및 status 관련 할당(생성/시작/완료 시)을 삭제함.
DB enum 삭제 → 런타임 enum 추가
edukit-core/src/main/java/com/edukit/core/studentrecord/db/enums/AITaskStatus.java (삭제), edukit-core/src/main/java/com/edukit/core/studentrecord/service/enums/AITaskStatus.java (추가)
영구 저장용 AITaskStatus enum 파일 삭제; 런타임용 새 AITaskStatus enum(PHASE1_STARTED, PHASE1_COMPLETED) 및 getMessageByStatus 유틸 추가.
SSE/AI DTO 도입
edukit-core/src/main/java/com/edukit/core/common/event/ai/dto/AIProgressMessage.java, .../SSEMessage.java
진행 메시지(AIProgressMessage)와 통합 SSE 봉투(SSEMessage + ProgressData/ResponseData) 레코드 및 정적 팩토리 추가.
AI 응답/이벤트 DTO: taskId 타입 변경
edukit-core/src/main/java/com/edukit/core/common/event/ai/dto/AIResponseMessage.java, .../DraftGenerationEvent.java
taskId 타입을 LongString로 변경(레코드/팩토리/생성자 시그니처 갱신).
AIEventListener: SSE 연동 및 SQS idempotencyKey 사용
edukit-core/src/main/java/com/edukit/core/common/event/ai/AIEventListener.java
SSEChannelManager 주입 추가, 작업 시작/완료 시 AIProgressMessage 전송 호출 추가, SQS 전송시 idempotencyKey(예: taskId-version) 전달.
SSEChannelManager: 통합 메시지 전송·진행 저장
edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java
응답을 SSEMessage.response로 래핑해 전송하고 이벤트명 통합(ai-message), Redis에 진행 상태 저장/TTL 처리, sendProgressMessage(...) 신규 메서드 및 채널 등록/제거 확장.
API/컨트롤러/페이사드: taskId 타입 변경 및 SSE 예시 갱신
edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIApi.java, .../StudentRecordAIController.java, .../facade/StudentRecordAIFacade.java, .../facade/response/StudentRecordTaskResponse.java
PathVariable 및 메서드 시그니처의 taskIdlongString으로 변경; createTaskId 등에서 문자열 ID 사용; SSE 예시/이벤트 페이로드 형식(프로그레스/응답 봉투)으로 갱신.
서비스: taskId 검증 및 파싱 변경
edukit-core/src/main/java/com/edukit/core/studentrecord/service/AITaskService.java
validateUserTask 시그니처를 longString으로 변경하고 parseTaskId 추가(숫자 파싱 실패 시 NUMBER_FORMAT_EXCEPTION 발생).
SQS API 변경 및 구현 업데이트
edukit-core/src/main/java/com/edukit/core/common/service/SqsService.java, edukit-external/src/main/java/com/edukit/external/aws/sqs/SqsServiceImpl.java
SqsService.sendMessageObject messageObject message, String idempotencyKey 시그니처로 변경; 구현에서 내부 idempotencyKey 추출 로직 제거하고 전달된 키 사용.
에러 코드 확장
edukit-core/src/main/java/com/edukit/core/studentrecord/exception/StudentRecordErrorCode.java
INVALID_AI_TASK_STATUSNUMBER_FORMAT_EXCEPTION 에러 코드 추가(열거자 목록 종결자 조정 포함).

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Client
  participant API
  participant AIEventListener
  participant AIService
  participant SSEChannelManager
  participant Redis
  participant SQS

  rect rgb(250,250,255)
    Client->>API: 스트리밍 채널(또는 생성 요청)
    API->>SSEChannelManager: register/createChannel(taskId)
    SSEChannelManager->>Redis: (옵션) GET taskStatusKey
    SSEChannelManager-->>Client: SSE "ai-message" (type=PROGRESS) [if status exists]
  end

  rect rgb(255,250,250)
    API->>AIEventListener: 요청 트리거(taskId as String)
    AIEventListener->>AIService: start generation
    AIEventListener->>SSEChannelManager: sendProgressMessage(taskId, generationStarted)
    AIEventListener->>SQS: sendMessage(event, idempotencyKey)
  end

  loop stream responses
    AIService-->>AIEventListener: AI chunk(finalContent, version)
    AIEventListener->>SSEChannelManager: sendMessage(taskId, AIResponseMessage)
    SSEChannelManager-->>Client: SSE "ai-message" (type=RESPONSE, data={finalContent,version})
    SSEChannelManager->>Redis: INCR responseCount / set TTL
  end

  rect rgb(250,255,250)
    AIEventListener->>SSEChannelManager: sendProgressMessage(taskId, generationCompleted)
    SSEChannelManager-->>Client: SSE "ai-message" (type=PROGRESS)
    SSEChannelManager->>Redis: SET taskStatusKey = PHASE1_COMPLETED (TTL)
  end

  note over SSEChannelManager,SQS: IOException/전송 실패 시 채널 제거 및 상태 정리
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • TaegeunYou

Poem

깡총깡총, 토끼가 뛰어와 🐇
문자열로 불러요, 상태칸은 비워요
진행은 퐁당 메시지로, 응답은 봉투에 쏙—
ai-message로 춤추며 전해요 🎶
당근으로 축하할게요! 🥕

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/EDMT-406

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
edukit-core/src/main/java/com/edukit/core/common/event/ai/AIEventListener.java (1)

27-33: AIEventListener에 SSEChannelManager 조건 누락
AIEventListener(@ConditionalOnBean {AIService, SqsService})에서 SSEChannelManager는 RedisStoreService 조건으로만 생성되어, 로컬/테스트 프로필 등 RedisStoreService 빈이 없을 경우 애플리케이션 부팅 시 DI 실패가 발생합니다.
다음 중 하나를 적용하세요.

옵션 A(권장): SSEChannelManager를 조건에 추가

- @ConditionalOnBean({AIService.class, SqsService.class})
+ @ConditionalOnBean({AIService.class, SqsService.class, SSEChannelManager.class})

옵션 B: Optional 주입 + 사용부 가드

- private final SSEChannelManager sseChannelManager;
+ private final Optional<SSEChannelManager> sseChannelManager;

사용부에서 sseChannelManager.ifPresent(m -> m.sendProgressMessage(...));로 감싸기.

🧹 Nitpick comments (8)
edukit-core/src/main/java/com/edukit/core/common/event/ai/dto/AIProgressMessage.java (2)

11-13: 널 입력 방지 가드 추가 권장

생성 팩토리에 간단한 null 가드를 넣어 직렬화/전달 시 NPE를 예방하세요.

 import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.Objects;
@@
-    public static AIProgressMessage of(final Long taskId, final String message) {
-        return new AIProgressMessage(taskId, message);
-    }
+    public static AIProgressMessage of(final Long taskId, final String message) {
+        return new AIProgressMessage(
+                Objects.requireNonNull(taskId, "taskId"),
+                Objects.requireNonNull(message, "message")
+        );
+    }

15-21: 하드코딩된 “3가지 버전” 제거(가변 버전 수 대응)

버전 수가 변경될 경우 메시지가 실제 동작과 어긋납니다. 오버로드를 추가해 확장성/일관성을 확보하세요. 기존 시그니처는 3을 기본값으로 위임하면 하위 호환됩니다.

-    public static AIProgressMessage generationStarted(final Long taskId) {
-        return new AIProgressMessage(taskId, "3가지 버전 생성 중");
-    }
+    public static AIProgressMessage generationStarted(final Long taskId) {
+        return generationStarted(taskId, 3);
+    }
+    public static AIProgressMessage generationStarted(final Long taskId, final int variantCount) {
+        return new AIProgressMessage(taskId, variantCount + "가지 버전 생성 중");
+    }
@@
-    public static AIProgressMessage generationCompleted(final Long taskId) {
-        return new AIProgressMessage(taskId, "3가지 버전 생성 완료");
-    }
+    public static AIProgressMessage generationCompleted(final Long taskId) {
+        return generationCompleted(taskId, 3);
+    }
+    public static AIProgressMessage generationCompleted(final Long taskId, final int variantCount) {
+        return new AIProgressMessage(taskId, variantCount + "가지 버전 생성 완료");
+    }
edukit-core/src/main/java/com/edukit/core/common/event/ai/dto/SSEMessage.java (1)

3-14: 문자열 기반 type → enum으로 강타입화

매직스트링을 제거해 오타/변경에 견고하게 만듭니다. 외부 사용처 변경 영향도 낮습니다(팩토리 메서드 유지).

-public record SSEMessage(
-        Long taskId,
-        String type,
-        Object data
-) {
+public record SSEMessage(
+        Long taskId,
+        Type type,
+        Object data
+) {
+    public enum Type { PROGRESS, RESPONSE }
@@
-    public static SSEMessage progress(final Long taskId, final String message) {
-        return new SSEMessage(taskId, "PROGRESS", new ProgressData(message));
+    public static SSEMessage progress(final Long taskId, final String message) {
+        return new SSEMessage(taskId, Type.PROGRESS, new ProgressData(message));
@@
-    public static SSEMessage response(final Long taskId, final String finalContent, final Integer version) {
-        return new SSEMessage(taskId, "RESPONSE", new ResponseData(finalContent, version));
+    public static SSEMessage response(final Long taskId, final String finalContent, final Integer version) {
+        return new SSEMessage(taskId, Type.RESPONSE, new ResponseData(finalContent, version));
edukit-core/src/main/java/com/edukit/core/studentrecord/db/entity/StudentRecordAITask.java (1)

56-62: 시간 획득 방식 개선(테스트 용이성/타임존 일관성)

LocalDateTime.now()는 테스트 고정과 타임존 일관성이 떨어집니다. 서비스 레이어에서 Clock을 주입해 시각을 인자로 전달하거나 Instant 기반으로 기록 후 뷰단에서 변환하는 방식을 권장합니다.

예: 서비스에서 호출

task.start(clock.instant());
task.complete(clock.instant());

엔티티

public void start(Instant now) { this.startedAt = LocalDateTime.ofInstant(now, ZoneOffset.UTC); }
public void complete(Instant now) { this.completedAt = LocalDateTime.ofInstant(now, ZoneOffset.UTC); }
edukit-core/src/main/java/com/edukit/core/common/event/ai/AIEventListener.java (2)

44-46: 하드코딩된 “3가지 버전” 표현 제거 및 키 재사용

주석의 “3가지”는 구현 변경 시 부정확해집니다. 또한 String.valueOf(taskId) 반복을 피하면 좋습니다.

-        // SSE로 3가지 버전 생성 시작 알림
-        sseChannelManager.sendProgressMessage(String.valueOf(taskId), AIProgressMessage.generationStarted(taskId));
+        // SSE로 버전 생성 시작 알림
+        final String taskKey = Long.toString(taskId);
+        sseChannelManager.sendProgressMessage(taskKey, AIProgressMessage.generationStarted(taskId));

97-99: 에러 경로에도 진행상황(실패) 알림을 전송하세요

현재 완료 알림만 있어 실패 시 클라이언트가 영원히 “진행 중”으로 보일 수 있습니다. 에러 콜백에서도 진행상황(실패) 메시지를 전송하는 것을 권장합니다.

원하시면 AIProgressMessagegenerationFailed(...) 팩토리를 추가하는 변경과 함께 적용 diff를 준비해드리겠습니다.

edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java (2)

55-60: 로그에 버전 정보 포함

응답 전송 로그에 버전 번호를 함께 남기면 트러블슈팅이 수월합니다.

-                log.info("Sent response message to SSE channel for taskId: {}", taskId);
+                log.info("Sent response message to SSE channel for taskId: {}, version: {}", taskId, message.version());

또한 message.taskId()/SSEMessage.response(...)의 taskId 타입(String/Long) 일치 여부를 확인해주세요. 필요 시 String.valueOf(message.taskId())로 안전하게 변환하세요.


74-88: 중복된 SSE 전송 로직 DRY화

sendMessagesendProgressMessage가 동일 패턴을 반복합니다. 공통 헬퍼로 추출해 유지보수성을 높이세요.

-    public void sendProgressMessage(final String taskId, final AIProgressMessage message) {
-        SseEmitter emitter = activeChannels.get(taskId);
-        if (emitter != null) {
-            try {
-                SSEMessage sseMessage = SSEMessage.progress(message.taskId(), message.message());
-                emitter.send(SseEmitter.event()
-                        .name(SSE_EVENT_NAME)
-                        .data(sseMessage));
-                log.info("Sent progress message to SSE channel for taskId: {}, message: {}", taskId, message.message());
-            } catch (IOException e) {
-                log.error("Failed to send progress message to SSE channel for taskId: {}", taskId, e);
-                removeChannel(taskId);
-            }
-        }
-    }
+    public void sendProgressMessage(final String taskId, final AIProgressMessage message) {
+        sendSse(taskId, SSEMessage.progress(message.taskId(), message.message()),
+                () -> log.info("Sent progress message to SSE channel for taskId: {}, message: {}", taskId, message.message()));
+    }

추가(파일 내 임의 위치, 클래스 private 메서드):

private void sendSse(final String taskId, final SSEMessage sseMessage, final Runnable onSuccess) {
    SseEmitter emitter = activeChannels.get(taskId);
    if (emitter != null) {
        try {
            emitter.send(SseEmitter.event().name(SSE_EVENT_NAME).data(sseMessage));
            onSuccess.run();
        } catch (IOException e) {
            log.error("Failed to send SSE to channel for taskId: {}", taskId, e);
            removeChannel(taskId);
        }
    }
}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between df022f7 and 52708e2.

📒 Files selected for processing (7)
  • edukit-api/src/main/resources/db/migration/V8__Remove_student_record_ai_task_columns.sql (1 hunks)
  • edukit-core/src/main/java/com/edukit/core/common/event/ai/AIEventListener.java (4 hunks)
  • edukit-core/src/main/java/com/edukit/core/common/event/ai/dto/AIProgressMessage.java (1 hunks)
  • edukit-core/src/main/java/com/edukit/core/common/event/ai/dto/SSEMessage.java (1 hunks)
  • edukit-core/src/main/java/com/edukit/core/studentrecord/db/entity/StudentRecordAITask.java (1 hunks)
  • edukit-core/src/main/java/com/edukit/core/studentrecord/db/enums/AITaskStatus.java (0 hunks)
  • edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java (4 hunks)
💤 Files with no reviewable changes (1)
  • edukit-core/src/main/java/com/edukit/core/studentrecord/db/enums/AITaskStatus.java
🔇 Additional comments (3)
edukit-api/src/main/resources/db/migration/V8__Remove_student_record_ai_task_columns.sql (1)

3-4: 방어적 컬럼 제거: IF EXISTS 추가

ALTER TABLE student_record_ai_task
DROP COLUMN IF EXISTS status;

rg 검색 결과 해당 컬럼/열거형 참조가 발견되지 않았으나, 운영 DB 호환성 강화를 위해 방어적으로 IF EXISTS를 사용하고, 인덱스·뷰·트리거 의존성이 없는지 프로덕션 환경에서 수동 검토하세요.

edukit-core/src/main/java/com/edukit/core/studentrecord/db/entity/StudentRecordAITask.java (1)

42-54: status 필드 제거 검증 완료
StudentRecordAITask 엔티티 및 관련 JPQL/네이티브 쿼리에서 status 참조가 전혀 남아있지 않음을 확인했습니다.

edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java (1)

32-32: SSE 이벤트 이름 호환성 확인
이 레포지토리에는 SSE_EVENT_NAME = "ai-message"를 사용하는 발신 측(SSEChannelManager)만 존재하며, 수신 측 구현이 없으므로 프론트엔드/클라이언트가 해당 이벤트명을 수신하도록 업데이트되었는지 반드시 확인하세요.

Comment on lines 3 to 7
public record SSEMessage(
Long taskId,
String type,
Object data
) {
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

❓ Verification inconclusive

JSON 필드 네이밍 일관성 확인 필요(snake_case vs camelCase)

AIProgressMessagetask_id를 사용하지만 여기서는 taskId로 직렬화될 가능성이 있습니다. 클라이언트 계약이 snake_case라면 명시적 어노테이션을 추가하세요. 프로젝트 전역 NamingStrategy 설정 여부도 확인 부탁드립니다.

 package com.edukit.core.common.event.ai.dto;
 
+import com.fasterxml.jackson.annotation.JsonProperty;
 
 public record SSEMessage(
-        Long taskId,
+        @JsonProperty("task_id")
+        Long taskId,
         String type,
         Object data
 ) {
@@
-    public record ResponseData(
-            String finalContent,
+    public record ResponseData(
+            @JsonProperty("final_content")
+            String finalContent,
             Integer version
     ) {
     }

전역 설정을 쓰는 경우 아래로 확인 부탁드립니다.

Also applies to: 16-24


🏁 Script executed:

#!/bin/bash
# Jackson 전역 네이밍 전략 사용 여부 탐색
rg -nP 'PropertyNamingStrategy|PropertyNamingStrategies|SNAKE_CASE' --type=java --type=yml -C2

Length of output: 27


🏁 Script executed:

#!/bin/bash
# Jackson 전역 네이밍 전략 설정 탐색(확장)
rg -nP '(PropertyNamingStrategy|PropertyNamingStrategies|property-naming-strategy|SNAKE_CASE)' -g '*.java' -g '*.yml' -g '*.yaml' -C2

Length of output: 0


Jackson 전역 NamingStrategy 미설정: JSON 필드에 snake_case 어노테이션 추가 필요
프로젝트 내 PropertyNamingStrategy/SNAKE_CASE 설정을 찾을 수 없으므로, 클라이언트 계약이 snake_case일 경우 아래 필드에 명시적 @JsonProperty 어노테이션을 추가하세요:

  • SSEMessage.taskId → @JsonProperty("task_id")
  • ResponseData.finalContent → @JsonProperty("final_content")
🤖 Prompt for AI Agents
In edukit-core/src/main/java/com/edukit/core/common/event/ai/dto/SSEMessage.java
around lines 3 to 7, the record components lack explicit JSON property names for
a snake_case client contract; annotate the taskId component with
@JsonProperty("task_id") on the record header and also locate the ResponseData
class and annotate its finalContent field or accessor with
@JsonProperty("final_content") so serialized JSON uses snake_case; ensure
imports for com.fasterxml.jackson.annotation.JsonProperty are added and that
annotations are applied to the record component and the ResponseData
field/getter accordingly.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java (1)

71-93: 중대: SSE 채널이 없으면 응답 카운트가 증가하지 않아 작업이 영원히 완료되지 않음

현재는 emitter 존재 시에만 응답 카운트 증가 및 완료 처리가 수행됩니다. 클라이언트가 스트림을 열지 않거나 끊긴 경우, DB 상 작업 완료가 누락될 수 있습니다.

아래와 같이 카운팅/완료 로직을 채널 존재 여부와 분리해 항상 수행해 주세요.

-    public void sendMessage(final String taskId, final AIResponseMessage message) {
-        SseEmitter emitter = activeChannels.get(taskId);
-        if (emitter != null) {
-            try {
-                SSEMessage sseMessage = SSEMessage.response(message.taskId(), message.reviewedContent(),
-                        message.version());
-                emitter.send(SseEmitter.event()
-                        .name(SSE_EVENT_NAME)
-                        .data(sseMessage));
-                log.info("Sent response message to SSE channel for taskId: {}", taskId);
-
-                Long responseCount = redisStoreService.increment(responseCountKey(taskId), RESPONSE_COUNT_TTL);
-                log.info("Response count for taskId {}: {}", taskId, responseCount);
-
-                if (responseCount >= MAX_RESPONSE_COUNT) {
-                    completeTask(taskId);
-                }
-            } catch (IOException e) {
-                log.error("Failed to send message to SSE channel for taskId: {}", taskId, e);
-                removeChannel(taskId);
-            }
-        }
-    }
+    public void sendMessage(final String taskId, final AIResponseMessage message) {
+        // 1) 응답 카운팅 및 완료 판정은 항상 수행
+        Long responseCount = redisStoreService.increment(responseCountKey(taskId), RESPONSE_COUNT_TTL);
+        log.info("Response count for taskId {}: {}", taskId, responseCount);
+
+        // 2) SSE 채널이 있으면 전송 시도
+        SseEmitter emitter = activeChannels.get(taskId);
+        if (emitter != null) {
+            try {
+                SSEMessage sseMessage = SSEMessage.response(message.taskId(), message.reviewedContent(), message.version());
+                emitter.send(SseEmitter.event().name(SSE_EVENT_NAME).data(sseMessage));
+                log.info("Sent response message to SSE channel for taskId: {}", taskId);
+            } catch (IOException e) {
+                log.error("Failed to send message to SSE channel for taskId: {}", taskId, e);
+                removeChannel(taskId);
+            }
+        } else {
+            log.info("No active SSE channel for taskId: {}, response stored/processed only", taskId);
+        }
+
+        // 3) 최대 응답 수 도달 시 작업 완료
+        if (responseCount != null && responseCount >= MAX_RESPONSE_COUNT) {
+            completeTask(taskId);
+        }
+    }
🧹 Nitpick comments (7)
edukit-core/src/main/java/com/edukit/core/studentrecord/service/enums/AITaskStatus.java (1)

17-23: 알 수 없는 상태 처리 시 null 리턴은 클라이언트 혼란을 유발할 수 있음

getMessageByStatus가 미매칭 시 null을 리턴합니다. 호출부(SSE 전송)에서 그대로 송신되면 data.message: null이 나갈 수 있습니다. 최소한 호출부에서 null 가드 또는 여기서 Optional로 감싸는 편이 안전합니다.

다음처럼 Optional을 리턴하도록 변경을 검토해 주세요(호출부 수정 필요):

-    public static String getMessageByStatus(final String status) {
-        return Arrays.stream(values())
-                .filter(aiTaskStatus -> aiTaskStatus.getStatus().equals(status))
-                .map(AITaskStatus::getMessage)
-                .findFirst()
-                .orElse(null);
-    }
+    public static java.util.Optional<String> getMessageByStatus(final String status) {
+        return Arrays.stream(values())
+                .filter(aiTaskStatus -> aiTaskStatus.getStatus().equals(status))
+                .map(AITaskStatus::getMessage)
+                .findFirst();
+    }
edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIApi.java (1)

142-143: 고정된 “3회 전송 시 종료” 문구는 상수 변경 시 문서 불일치 위험

MAX_RESPONSE_COUNT가 바뀌면 문서와 실제 동작이 어긋납니다. “최대 3회(현재 기준)”처럼 표현을 완화하거나, 운영 가이드에만 명시하는 방식을 고려해 주세요.

edukit-core/src/main/java/com/edukit/core/common/event/ai/dto/AIProgressMessage.java (1)

1-20: 진행 이벤트 도입 LGTM

팩토리 메서드 분리로 사용성이 좋습니다. 추후 실패 케이스를 전파하려면 enum과 함께 generationFailed(...) 확장 여지 있습니다.

edukit-core/src/main/java/com/edukit/core/common/event/ai/AIEventListener.java (1)

44-46: SSE 진행 알림 타이밍 적절 — 에러 케이스도 고려해 주세요

시작/완료 알림 포인트 배치가 합리적입니다. 다만 오류 발생 시 진행상태가 “완료”로 마무리되지 않으므로, 실패 상태(예: PHASE1_FAILED)를 추가해 전파하는 것을 권장합니다.

가능한 변경 예:

  • AITaskStatusPHASE1_FAILED 추가
  • error 콜백에서 sendProgressMessage(taskId, AIProgressMessage.generationFailed(taskId)) 전송

Also applies to: 97-100

edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java (3)

67-69: 메서드명 오타(naming): hasActivateChannel → hasActiveChannel 권장

공용 API라면 오타는 누적 혼란을 줍니다. 리네임 + 사용처 변경을 권장합니다.

-    public boolean hasActivateChannel(final String taskId) {
+    public boolean hasActiveChannel(final String taskId) {
         return activeChannels.containsKey(taskId);
     }

120-131: 채널 제거 시 응답 카운트 키도 정리하는 것이 안전

현재 response-count: 키는 TTL(5분) 만료를 기다립니다. 즉시 청소하면 재사용/오탐을 더 줄일 수 있습니다.

         } finally {
             redisStoreService.delete(sseChannelKey(taskId));
             redisStoreService.delete(taskStatusKey(taskId));
+            redisStoreService.delete(responseCountKey(taskId));
             log.info("Removed SSE channel for taskId: {}", taskId);
         }

135-146: 현재 모든 taskId는 숫자 전용이므로 변환 실패 위험 없음
registerTaskChannel, AIEventListener, RedisStreamConsumer 등에서 모두 String.valueOf(long)으로 전달되어 Long.valueOf(taskId) 변환이 항상 성공합니다. 향후 UUID 등 비숫자 ID 체계 도입 시에는 메소드 시그니처를 Long으로 변경하거나 Javadoc으로 숫자 전용임을 명시하는 리팩터링을 고려하세요.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 52708e2 and 573546f.

📒 Files selected for processing (8)
  • edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIApi.java (2 hunks)
  • edukit-core/src/main/java/com/edukit/core/common/event/ai/AIEventListener.java (3 hunks)
  • edukit-core/src/main/java/com/edukit/core/common/event/ai/dto/AIProgressMessage.java (1 hunks)
  • edukit-core/src/main/java/com/edukit/core/common/event/ai/dto/AIResponseMessage.java (1 hunks)
  • edukit-core/src/main/java/com/edukit/core/common/event/ai/dto/DraftGenerationEvent.java (1 hunks)
  • edukit-core/src/main/java/com/edukit/core/common/event/ai/dto/SSEMessage.java (1 hunks)
  • edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java (6 hunks)
  • edukit-core/src/main/java/com/edukit/core/studentrecord/service/enums/AITaskStatus.java (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • edukit-core/src/main/java/com/edukit/core/common/event/ai/dto/SSEMessage.java
🔇 Additional comments (3)
edukit-core/src/main/java/com/edukit/core/common/event/ai/dto/AIResponseMessage.java (1)

7-7: 하위 호환성 점검 완료
AIResponseMessage 생성·소비 지점(edu­kit-core/src/main/java/com/edukit/core/studentrecord/service/RedisStreamConsumer.java, SSEChannelManager.java) 모두 String taskId로 일관되게 적용되었습니다.

edukit-core/src/main/java/com/edukit/core/common/event/ai/dto/DraftGenerationEvent.java (1)

4-4: 이벤트 퍼블리셔/컨슈머 검증 완료
모든 DraftGenerationEvent.of 호출이 String 파라미터 기준(taskId, requestPrompt, byteCount, version, draftContent, traceId)으로 일치하며 추가 조치 불필요합니다.

edukit-core/src/main/java/com/edukit/core/common/event/ai/AIEventListener.java (1)

64-74: (스크립트 실행 대기 중...)

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIApi.java (1)

176-184: 오류 예시의 코드/메시지 불일치 수정 필요

"작업 미존재" 케이스는 SR-40406 / "AI 작업을 찾을 수 없습니다."가 맞습니다. 현재 예시는 학생 기록 미존재 코드/메시지를 사용합니다.

적용 diff:

-                                                    {
-                                                      "code": "SR-40401",
-                                                      "message": "해당 학생 기록이 존재하지 않습니다."
-                                                    }
+                                                    {
+                                                      "code": "SR-40406",
+                                                      "message": "AI 작업을 찾을 수 없습니다."
+                                                    }
edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java (1)

119-131: Redis 키 정리 보장

activeChannels에 없으면 키가 삭제되지 않습니다. 키 삭제는 emitter 유무와 무관하게 보장하세요.

적용 diff:

-    public void removeChannel(final String taskId) {
-        SseEmitter emitter = activeChannels.remove(taskId);
-        if (emitter != null) {
-            try {
-                emitter.complete();
-            } catch (Exception e) {
-                log.warn("SSE complete failed for taskId: {}", taskId, e);
-            } finally {
-                redisStoreService.delete(sseChannelKey(taskId));
-                redisStoreService.delete(taskStatusKey(taskId));
-                log.info("Removed SSE channel for taskId: {}", taskId);
-            }
-        }
-    }
+    public void removeChannel(final String taskId) {
+        SseEmitter emitter = activeChannels.remove(taskId);
+        try {
+            if (emitter != null) {
+                emitter.complete();
+            }
+        } catch (Exception e) {
+            log.warn("SSE complete failed for taskId: {}", taskId, e);
+        } finally {
+            redisStoreService.delete(sseChannelKey(taskId));
+            redisStoreService.delete(taskStatusKey(taskId));
+            log.info("Removed SSE channel for taskId: {}", taskId);
+        }
+    }
♻️ Duplicate comments (2)
edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIApi.java (1)

221-223: PathVariable를 String으로 통일한 점 확인

이전 리뷰 지적 사항이 반영되었습니다.

레포 전반에 남아 있을 수 있는 long|Long taskId 사용처를 확인해 주세요:

#!/bin/bash
rg -nP --type=java -C2 '(?:@PathVariable|@RequestParam|\\b)\\s*(?:long|Long)\\s+taskId\\b'
edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java (1)

39-45: Emitter 콜백 등록으로 누수 방지

클라이언트 종료/타임아웃 시 자원 정리를 보장하세요. (Controller에도 있으나, Manager 레벨에서도 방어적으로 두는 것을 권장)

적용 diff:

     public void registerTaskChannel(final String taskId, final SseEmitter emitter) {
         String serverId = serverInstanceManager.getServerId();
         redisStoreService.store(sseChannelKey(taskId), serverId, Duration.ofHours(1));
         activeChannels.put(taskId, emitter);
         log.info("Registered SSE channel for taskId: {} on server: {}", taskId, serverId);
+
+        emitter.onCompletion(() -> removeChannel(taskId));
+        emitter.onTimeout(() -> {
+            log.warn("SSE timeout for taskId: {}", taskId);
+            removeChannel(taskId);
+        });
+        emitter.onError(ex -> {
+            log.warn("SSE error for taskId: {}", taskId, ex);
+            removeChannel(taskId);
+        });
🧹 Nitpick comments (5)
edukit-core/src/main/java/com/edukit/core/studentrecord/exception/StudentRecordErrorCode.java (1)

18-20: 마지막 enum 상수 뒤 불필요한 콤마 제거 권장

컴파일엔 문제 없으나 스타일상 제거가 깔끔합니다.

적용 diff:

-    NUMBER_FORMAT_EXCEPTION("SR-40010", "숫자 형식이 올바르지 않습니다."),;
+    NUMBER_FORMAT_EXCEPTION("SR-40010", "숫자 형식이 올바르지 않습니다.");
edukit-core/src/main/java/com/edukit/core/studentrecord/service/AITaskService.java (2)

37-39: 읽기 전용 트랜잭션 옵션 제안

조회 전용이므로 @Transactional(readOnly = true) 적용을 권장합니다.

적용 diff:

-    public void validateUserTask(final long memberId, final String taskId) {
+    @Transactional(readOnly = true)
+    public void validateUserTask(final long memberId, final String taskId) {

44-50: 예외 래핑 시 원인(cause) 포함

원인 스택 추적을 보존하도록 cause를 전달하세요.

적용 diff:

-        } catch (NumberFormatException e) {
-            throw new StudentRecordException(StudentRecordErrorCode.NUMBER_FORMAT_EXCEPTION);
+        } catch (NumberFormatException e) {
+            throw new StudentRecordException(StudentRecordErrorCode.NUMBER_FORMAT_EXCEPTION, e);
edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordAIFacade.java (1)

43-49: SSE 타임아웃 상수화/설정값 노출 고려

new SseEmitter(10 * 60 * 1000L)은 하드코딩되어 있어 설정으로 분리하면 운영 조정이 수월합니다.

edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java (1)

66-68: 메서드명 타이포

hasActivateChannelhasActiveChannel로 수정 권장.

적용 diff:

-    public boolean hasActivateChannel(final String taskId) {
+    public boolean hasActiveChannel(final String taskId) {
         return activeChannels.containsKey(taskId);
     }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 573546f and 13b002c.

📒 Files selected for processing (8)
  • edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIApi.java (4 hunks)
  • edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIController.java (1 hunks)
  • edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordAIFacade.java (1 hunks)
  • edukit-api/src/main/java/com/edukit/studentrecord/facade/response/StudentRecordTaskResponse.java (1 hunks)
  • edukit-core/src/main/java/com/edukit/core/studentrecord/exception/StudentRecordErrorCode.java (1 hunks)
  • edukit-core/src/main/java/com/edukit/core/studentrecord/service/AITaskService.java (1 hunks)
  • edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java (6 hunks)
  • edukit-core/src/main/java/com/edukit/core/studentrecord/service/enums/AITaskStatus.java (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
edukit-core/src/main/java/com/edukit/core/studentrecord/service/enums/AITaskStatus.java (1)
edukit-core/src/main/java/com/edukit/core/studentrecord/exception/StudentRecordException.java (1)
  • StudentRecordException (5-14)
🔇 Additional comments (10)
edukit-api/src/main/java/com/edukit/studentrecord/facade/response/StudentRecordTaskResponse.java (2)

7-7: taskId 타입을 String으로 전환한 결정에 동의

API 전반의 문자열 기반 taskId 표준화와 일관됩니다. Swagger 예시도 문자열로 맞습니다.


9-11: 팩토리 메서드 시그니처 일관화 OK

of(final String taskId)로의 변경이 상위 레이어와 정합합니다.

edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIController.java (1)

39-41: 스트리밍 엔드포인트의 taskId를 String으로 변경한 점 확인

Facade/Service 계층의 변경과 일치하며, 경로 변수 파싱 책임이 서비스로 위임되어 좋습니다.

edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordAIFacade.java (2)

40-41: 문자열 taskId 응답 매핑 적절

String.valueOf(task.getId())로 공개 DTO에 맞춘 점 좋습니다.


51-53: 채널 정리 위임 방식 OK

Controller에서의 콜백과 Manager의 정리가 호환됩니다.

edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIApi.java (2)

142-143: 스트리밍 설명 갱신 적절

진행/응답 메시지 및 자동 종료 정책이 구현과 일치합니다.


154-168: SSE 이벤트 예시가 구현과 일치

이벤트명(ai-message)과 페이로드 형태가 Manager 상수/DTO와 일관됩니다.

edukit-core/src/main/java/com/edukit/core/studentrecord/service/enums/AITaskStatus.java (1)

12-24: 진행 상태 → 사용자 메시지 매핑 명확

간결하고 의도가 분명합니다. 알 수 없는 상태에 예외를 던지는 정책도 타당합니다.

edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java (2)

34-38: 상수 일관성 OK

SSE_EVENT_NAME = "ai-message"가 API 문서와 일치합니다.


70-80: 검증 완료: SSE 메시지 응답의 finalContent JSON 필드가 문서 예시와 일치합니다.

Comment on lines +45 to +59
// SSE 채널 등록 시 현재 진행 상태가 있다면 전송
String currentStatus = redisStoreService.get(taskStatusKey(taskId));
if (currentStatus != null) {
try {
String message = AITaskStatus.getMessageByStatus(currentStatus);
SSEMessage sseMessage = SSEMessage.progress(taskId, message);
emitter.send(SseEmitter.event()
.name(SSE_EVENT_NAME)
.data(sseMessage));
log.info("Sent stored progress message to SSE channel for taskId: {}, message: {}", taskId, message);
} catch (IOException e) {
log.error("Failed to send stored progress message to SSE channel for taskId: {}", taskId, e);
removeChannel(taskId);
}
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

저장된 진행 상태 전송 시 알 수 없는 상태 예외 처리 누락

AITaskStatus.getMessageByStatus가 예외를 던지면 채널 등록이 실패할 수 있습니다. 안전하게 무시/경고 로그로 처리하세요.

적용 diff:

         String currentStatus = redisStoreService.get(taskStatusKey(taskId));
         if (currentStatus != null) {
             try {
                 String message = AITaskStatus.getMessageByStatus(currentStatus);
                 SSEMessage sseMessage = SSEMessage.progress(taskId, message);
-                emitter.send(SseEmitter.event()
-                        .name(SSE_EVENT_NAME)
-                        .data(sseMessage));
+                emitter.send(SseEmitter.event().name(SSE_EVENT_NAME).data(sseMessage));
                 log.info("Sent stored progress message to SSE channel for taskId: {}, message: {}", taskId, message);
+            } catch (StudentRecordException sre) {
+                log.warn("Unknown stored status for taskId: {}, status: {}", taskId, currentStatus);
             } catch (IOException e) {
                 log.error("Failed to send stored progress message to SSE channel for taskId: {}", taskId, e);
                 removeChannel(taskId);
             }
         }
📝 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.

Suggested change
// SSE 채널 등록 시 현재 진행 상태가 있다면 전송
String currentStatus = redisStoreService.get(taskStatusKey(taskId));
if (currentStatus != null) {
try {
String message = AITaskStatus.getMessageByStatus(currentStatus);
SSEMessage sseMessage = SSEMessage.progress(taskId, message);
emitter.send(SseEmitter.event()
.name(SSE_EVENT_NAME)
.data(sseMessage));
log.info("Sent stored progress message to SSE channel for taskId: {}, message: {}", taskId, message);
} catch (IOException e) {
log.error("Failed to send stored progress message to SSE channel for taskId: {}", taskId, e);
removeChannel(taskId);
}
}
// SSE 채널 등록 시 현재 진행 상태가 있다면 전송
String currentStatus = redisStoreService.get(taskStatusKey(taskId));
if (currentStatus != null) {
try {
String message = AITaskStatus.getMessageByStatus(currentStatus);
SSEMessage sseMessage = SSEMessage.progress(taskId, message);
emitter.send(SseEmitter.event().name(SSE_EVENT_NAME).data(sseMessage));
log.info("Sent stored progress message to SSE channel for taskId: {}, message: {}", taskId, message);
} catch (StudentRecordException sre) {
log.warn("Unknown stored status for taskId: {}, status: {}", taskId, currentStatus);
} catch (IOException e) {
log.error("Failed to send stored progress message to SSE channel for taskId: {}", taskId, e);
removeChannel(taskId);
}
}
🤖 Prompt for AI Agents
In
edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java
around lines 45 to 59, AITaskStatus.getMessageByStatus may throw an exception
which currently would abort channel registration; wrap the call that obtains the
message in a separate try/catch (catch RuntimeException or the specific
unchecked exception thrown) so that if it fails you log a warning and skip
sending the stored progress message without removing the channel or failing
registration, while preserving the existing IOException handling for
emitter.send.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (7)
edukit-core/src/main/java/com/edukit/core/common/service/SqsService.java (1)

5-5: 기능 호환성 보장을 위한 디폴트 메서드 오버로드 및 계약 명시

File: edukit-core/src/main/java/com/edukit/core/common/service/SqsService.java
외부 구현체와의 바이너리/소스 호환성을 유지하려면, 다음과 같이 기본 sendMessage(Object message) 디폴트 메서드를 추가하고
idempotencyKey의 null·공백 허용 여부를 Javadoc으로 명시하세요.

제안 diff:

 public interface SqsService {
+    /**
+     * Sends a message without an idempotency key.
+     * @param message 전송할 메시지, null 불가
+     */
+    default void sendMessage(Object message) {
+        sendMessage(message, null);
+    }
     void sendMessage(Object message, String idempotencyKey);
 }
edukit-external/src/main/java/com/edukit/external/aws/sqs/SqsServiceImpl.java (4)

57-62: 메시지 크기 검증이 Attribute 크기를 반영하지 않습니다

SQS는 Body+Attributes 모두 합산 256KB 제한입니다. 현재는 Body만 검사합니다. Attribute(특히 idempotencyKey)가 추가되면 경계값에서 실패 위험이 있습니다. Attribute를 포함한 총량 검증으로 이관을 권장합니다.

권장 diff(개념 예):

-    private void validateMessageSize(final int messageSizeBytes) {
-        if (messageSizeBytes > SQS_MAX_MESSAGE_SIZE) {
+    private void validateMessageSize(final int totalSizeBytes) {
+        if (totalSizeBytes > SQS_MAX_MESSAGE_SIZE) {
             log.error("메시지 크기가 SQS 제한(256KB)을 초과했습니다 - 메시지 크기 초과로 전송 실패: {} bytes", messageSizeBytes);
             throw new SQSException(SQSErrorCode.MESSAGE_SIZE_EXCEEDED);
         }
     }

그리고 sendMessage(...)에서 Body만이 아니라 sendMessageInternal(...)에서 Attribute 구성 후 아래처럼 총량을 계산해 호출:

-    validateMessageSize(messageSizeBytes);
+    // ... sendMessageInternal에서 계산
-    private void sendMessageInternal(final String messageBody, final String idempotencyKey) {
+    private void sendMessageInternal(final String messageBody, final String idempotencyKey) {
         Map<String, MessageAttributeValue> messageAttributes = new HashMap<>();
         ...
+        int attrsBytes = messageAttributes.entrySet().stream()
+            .mapToInt(e -> e.getKey().getBytes(StandardCharsets.UTF_8).length
+                + (e.getValue().stringValue() != null ? e.getValue().stringValue().getBytes(StandardCharsets.UTF_8).length : 0))
+            .sum();
+        int totalSize = messageBody.getBytes(StandardCharsets.UTF_8).length + attrsBytes;
+        validateMessageSize(totalSize);

83-88: idempotencyKey 공백 문자열 처리

널만 체크하면 공백 키가 Attribute로 붙을 수 있습니다. StringUtils.hasText(...)로 보강을 권장합니다.

권장 diff:

+import org.springframework.util.StringUtils;
 ...
-        if (idempotencyKey != null) {
+        if (StringUtils.hasText(idempotencyKey)) {
             messageAttributes.put("idempotencyKey", MessageAttributeValue.builder()
                     .dataType("String")
                     .stringValue(idempotencyKey)
                     .build());
         }

90-96: SQS 멱등 보장 목적이라면 FIFO 설정/필드가 필요합니다

멱등키를 Attribute로만 전송하면 “소비자 측 중복 제거”에만 활용됩니다. 전송 레벨에서의 멱등/순서 보장을 의도했다면 FIFO 큐 + messageGroupId/messageDeduplicationId가 필요합니다. 현재는 두 필드가 미설정입니다(표준 큐라면 그대로 OK).

  • 실제 큐가 FIFO(.fifo)인지, 표준 큐인지 확인 부탁드립니다.
  • FIFO라면 아래처럼 조건부 설정을 권장합니다.

권장 diff(큐 URL로 단순 판별 예):

+import java.util.UUID;
+import org.springframework.util.StringUtils;
 ...
-        final SendMessageRequest request = sqsClient.sendMessage(request);
+        boolean isFifo = sqsProperties.queueUrl() != null && sqsProperties.queueUrl().endsWith(".fifo");
+
+        SendMessageRequest.Builder builder = SendMessageRequest.builder()
+                .queueUrl(sqsProperties.queueUrl())
+                .messageBody(messageBody)
+                .messageAttributes(messageAttributes);
+
+        if (isFifo) {
+            builder = builder
+                .messageGroupId("ai-draft-" + (StringUtils.hasText(userId) ? userId : "default"))
+                .messageDeduplicationId(StringUtils.hasText(idempotencyKey) ? idempotencyKey : UUID.randomUUID().toString());
+        }
+
+        final SendMessageRequest request = builder.build();
+        final SendMessageResponse response = sqsClient.sendMessage(request);

FIFO가 아니라면(표준 큐) 현재 구현대로 Attribute로 내려보내고, 컨슈머에서 멱등 처리하는 전략이면 충분합니다. 해당 전략을 README/컨슈머 코드에 명시해 주세요.

Also applies to: 83-88


97-99: 로그: 민감도 및 가시성 밸런스 OK, 단 null 표시를 명시적으로

idempotencyKey가 null일 수 있으니 로그에 기본값을 넣어 노이즈를 줄이는 것도 방법입니다(선택).

권장 diff:

-        log.info("SQS 메시지 전송 완료 - MessageId: {}, TraceId: {}, IdempotencyKey: {}",
-                response.messageId(), traceId, idempotencyKey);
+        log.info("SQS 메시지 전송 완료 - MessageId: {}, TraceId: {}, IdempotencyKey: {}",
+                response.messageId(), traceId, (idempotencyKey != null ? idempotencyKey : "N/A"));
edukit-core/src/main/java/com/edukit/core/common/event/ai/AIEventListener.java (2)

44-46: SSE 전송 실패가 전체 플로우를 막지 않도록 캡슐화

SSE 채널이 끊긴 경우 예외가 전파되면 작업 흐름에 영향이 있을 수 있습니다. try-catch로 흡수하고 로그만 남기는 것을 권장합니다.

권장 diff:

-        // SSE로 3가지 버전 생성 시작 알림
-        sseChannelManager.sendProgressMessage(taskId, AIProgressMessage.generationStarted(taskId));
+        // SSE로 3가지 버전 생성 시작 알림
+        try {
+            sseChannelManager.sendProgressMessage(taskId, AIProgressMessage.generationStarted(taskId));
+        } catch (Exception sseEx) {
+            log.warn("SSE 시작 알림 전송 실패 - taskId: {}, reason: {}", taskId, sseEx.getMessage());
+        }

99-101: 완료 알림도 보호적 전송 권장

완료 시점 SSE도 동일하게 보호해 주세요.

권장 diff:

-        // SSE로 3가지 버전 생성 완료 알림
-        sseChannelManager.sendProgressMessage(taskId,
-                AIProgressMessage.generationCompleted(taskId));
+        // SSE로 3가지 버전 생성 완료 알림
+        try {
+            sseChannelManager.sendProgressMessage(taskId, AIProgressMessage.generationCompleted(taskId));
+        } catch (Exception sseEx) {
+            log.warn("SSE 완료 알림 전송 실패 - taskId: {}, reason: {}", taskId, sseEx.getMessage());
+        }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 13b002c and 2d507e0.

📒 Files selected for processing (3)
  • edukit-core/src/main/java/com/edukit/core/common/event/ai/AIEventListener.java (4 hunks)
  • edukit-core/src/main/java/com/edukit/core/common/service/SqsService.java (1 hunks)
  • edukit-external/src/main/java/com/edukit/external/aws/sqs/SqsServiceImpl.java (2 hunks)
🔇 Additional comments (2)
edukit-external/src/main/java/com/edukit/external/aws/sqs/SqsServiceImpl.java (1)

34-55: 직렬화/전송 예외 처리 흐름은 적절합니다

메시지 직렬화/전송 예외를 구분해 래핑하는 전략은 👍 입니다.

edukit-core/src/main/java/com/edukit/core/common/event/ai/AIEventListener.java (1)

74-76: 멱등키 구성은 명확하고 단순해서 👍, 다만 큐 타입에 따라 의미가 달라집니다

taskId-version 포맷은 재시도 시 중복 전송 방지에 유용합니다. 다만 표준 큐면 컨슈머 쪽 중복 제거와 함께, FIFO 큐면 messageDeduplicationId로 활용되도록 송신 측 설정이 필요합니다(별도 코멘트 참조).

큐 타입(FIFO/Standard) 확인 부탁드립니다.

Comment on lines +33 to 34
private final SSEChannelManager sseChannelManager;

Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

조건부 빈 주입 조건에 SSEChannelManager 포함 권장

새 의존성을 생성자에 추가했지만 @ConditionalOnBean에는 포함되지 않아 특정 프로필/테스트 컨텍스트에서 빈 미존재 시 구동 실패 위험이 있습니다.

권장 diff:

-@ConditionalOnBean({AIService.class, SqsService.class})
+@ConditionalOnBean({AIService.class, SqsService.class, SSEChannelManager.class})
📝 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.

Suggested change
private final SSEChannelManager sseChannelManager;
@@
@ConditionalOnBean({AIService.class, SqsService.class, SSEChannelManager.class})
public class AIEventListener {
private final SSEChannelManager sseChannelManager;
// …
}
🤖 Prompt for AI Agents
In
edukit-core/src/main/java/com/edukit/core/common/event/ai/AIEventListener.java
around lines 33-34, you added a new constructor dependency private final
SSEChannelManager sseChannelManager but did not include SSEChannelManager in the
class's @ConditionalOnBean check; update the class-level conditional to include
SSEChannelManager.class (or alternatively make the dependency optional via
@Autowired(required=false) or ObjectProvider<SSEChannelManager>) so the bean
won't fail to instantiate in profiles/tests where SSEChannelManager is absent.

@kgy1008 kgy1008 merged commit 6dc1002 into develop Sep 7, 2025
2 checks passed
@kgy1008 kgy1008 deleted the feat/EDMT-406 branch September 7, 2025 15:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants